Flask Session -> Double Token

This commit is contained in:
喆OvO
2026-08-11 19:57:21 +08:00
parent 0fef309972
commit cbbb2e6d34
18 changed files with 1375 additions and 607 deletions
+34 -31
View File
@@ -16,29 +16,10 @@
});
// ─── Helpers ──────────────────────────────
// 统一请求封装:自动附加 Authorization: Bearer <access>,401 时自动刷新并重试,
// 刷新失败则清空 token 并跳转登录页(见 token.js)。
function api(method, url, data, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if (data && !(data instanceof FormData)) {
xhr.setRequestHeader('Content-Type', 'application/json');
}
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?view=control&next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
if (callback) callback(JSON.parse(xhr.responseText || '{}'));
};
if (data instanceof FormData) {
xhr.send(data);
} else if (data) {
xhr.send(JSON.stringify(data));
} else {
xhr.send();
}
Auth.request(method, url, data, callback);
}
// HTML escape for untrusted data — prevents XSS in innerHTML
@@ -542,14 +523,18 @@
$('kick-all-btn').addEventListener('click', function () {
var msg = $('mode-msg');
api('POST', '/api/display/kick_all', null, function (res) {
if (res.ok) {
msg.textContent = '已踢掉所有显示端';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '操作失败';
msg.style.color = '#E05555';
}
showConfirm('踢掉当前账号的所有设备', '将立即下线本账号的全部设备(含当前控制端),确定?', function () {
msg.textContent = '正在下线全部设备...';
api('POST', '/api/display/kick_all', null, function (res) {
if (res.ok) {
// 当前设备也会被踢下线:由 401 → refresh 失败 → 跳转登录页完成
msg.textContent = '已下线本账号全部设备,即将跳转登录页';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '操作失败';
msg.style.color = '#E05555';
}
});
});
});
@@ -579,7 +564,7 @@
// ─── Logout ────────────────────────────────
$('logout-btn').addEventListener('click', function () {
api('POST', '/api/auth/logout', null, function () {
Auth.logout(function () {
location.href = '/login?view=control';
});
});
@@ -1369,6 +1354,24 @@
$('user-refresh-btn').addEventListener('click', loadUsers);
// Root: kick every user's devices (control + display, all accounts)
var kickAllUsersBtn = $('kick-all-users-btn');
if (kickAllUsersBtn) {
kickAllUsersBtn.addEventListener('click', function () {
showConfirm('踢掉所有用户', '所有用户的控制端与显示端将立即全部下线(包含当前设备),确定?', function () {
_reauthThen(function () {
api('POST', '/api/admin/kick_all_users', null, function (res) {
if (res.ok) {
toast('已踢掉所有用户,即将下线');
} else {
toast(res.error || '操作失败');
}
});
});
});
});
}
// ─── Init ─────────────────────────────────
loadGroups();
updateEventGroupSelects();
+115 -154
View File
@@ -35,6 +35,7 @@
update_schedule: {},
_cdModalMode: false, // true = countdown modal, false = alarm modal
_editingCountdown: null,
timezone: 'Asia/Shanghai', // 账户独立时区(来自 /api/display/state)
};
// ─── DOM refs ─────────────────────────────
@@ -48,6 +49,48 @@
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ─── 账户时区时间 ─────────────────────────
// 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断
// 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。
function _pad2(n) { return String(n).padStart(2, '0'); }
function _tzNow() {
var tz = state.timezone || 'Asia/Shanghai';
var out = { y: 1970, mo: 1, d: 1, h: 0, mi: 0, s: 0, wd: 0 };
try {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23', weekday: 'short'
}).formatToParts(new Date());
var wdMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
parts.forEach(function (p) {
switch (p.type) {
case 'year': out.y = parseInt(p.value, 10); break;
case 'month': out.mo = parseInt(p.value, 10); break; // 1-12
case 'day': out.d = parseInt(p.value, 10); break;
case 'hour': out.h = parseInt(p.value, 10); break;
case 'minute': out.mi = parseInt(p.value, 10); break;
case 'second': out.s = parseInt(p.value, 10); break;
case 'weekday': out.wd = wdMap[p.value] || 0; break;
}
});
} catch (e) {
var l = new Date();
out = { y: l.getFullYear(), mo: l.getMonth() + 1, d: l.getDate(),
h: l.getHours(), mi: l.getMinutes(), s: l.getSeconds(), wd: l.getDay() };
}
return out;
}
// 生成 "YYYYMMDDTHHMMSS" 比较键(与后端本地时间字符串可比)
function _nowLocalKey() {
var n = _tzNow();
return String(n.y) + _pad2(n.mo) + _pad2(n.d) + 'T' +
_pad2(n.h) + _pad2(n.mi) + _pad2(n.s);
}
// ─── Panel switching ──────────────────────
function switchPanel(name) {
if (state.alarmActive && name !== 'alarm') return; // lock during alarm
@@ -74,36 +117,36 @@
// ─── Data fetching ────────────────────────
function fetchState() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/display/state');
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
state.weather = data.weather || {};
state.hourly = data.hourly || [];
state.events = data.events || [];
state.alarms = data.alarms || [];
state.countdowns = data.countdowns || [];
state.update_schedule = data.update_schedule || {};
state.minutely = data.minutely || null;
state.warnings = data.warnings || null;
state.groups = data.groups || [];
renderAll();
}
};
xhr.send();
Auth.request('GET', '/api/display/state', null, function (data, status) {
if (status !== 200) return;
state.weather = data.weather || {};
state.hourly = data.hourly || [];
state.events = data.events || [];
state.alarms = data.alarms || [];
state.countdowns = data.countdowns || [];
state.update_schedule = data.update_schedule || {};
state.minutely = data.minutely || null;
state.warnings = data.warnings || null;
state.groups = data.groups || [];
state.timezone = data.timezone || state.timezone || 'Asia/Shanghai';
renderAll();
});
}
// ─── SSE for real-time events ─────────────
function connectSSE() {
var es = new EventSource('/api/events');
// EventSource 不能带 Authorization header → 先换一次性 ticket
Auth.request('POST', '/api/events/ticket', null, function (res, status) {
if (status !== 201 || !res.ticket) {
setTimeout(connectSSE, 5000);
return;
}
openSSE('/api/events?ticket=' + encodeURIComponent(res.ticket));
});
}
function openSSE(url) {
var es = new EventSource(url);
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
@@ -112,24 +155,19 @@
};
es.onerror = function () {
es.close();
clearInterval(state._sseGuard);
setTimeout(connectSSE, 5000);
};
// SSE doesn't give us status codes; if auth fails the browser will error-loop.
// We add a small guard: if we get no messages for 30s, try a normal fetch to check auth.
state._sseGuard = setInterval(function () {
var g = new XMLHttpRequest();
g.open('GET', '/api/display/state');
g.onload = function () {
if (g.status === 401) {
var res = JSON.parse(g.responseText || '{}');
if (res.login_required) {
es.close();
clearInterval(state._sseGuard);
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}
Auth.request('GET', '/api/display/state', null, function (data, st) {
if (st === 401) {
es.close();
clearInterval(state._sseGuard);
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}
};
g.send();
});
}, 30000);
}
@@ -175,12 +213,9 @@
// 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画)
if (window.ThemeBridge) ThemeBridge.fetch();
} else if (msg.type === 'kick_all') {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () {
Auth.logout(function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
};
xhr.send();
});
}
}
@@ -401,20 +436,9 @@
// ─── Alarm CRUD ───────────────────────────
function apiJson(method, url, data, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if (data) { xhr.setRequestHeader('Content-Type', 'application/json'); }
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
if (callback) callback(JSON.parse(xhr.responseText || '{}'));
};
xhr.send(data ? JSON.stringify(data) : null);
Auth.request(method, url, data, function (res, status) {
if (callback) callback(res, status);
});
}
function deleteAlarm(id) {
@@ -841,12 +865,9 @@
var lockBtn = $('lock-btn');
if (lockBtn) {
lockBtn.addEventListener('click', function () {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () {
Auth.logout(function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
};
xhr.send();
});
});
}
@@ -943,34 +964,19 @@
// ─── Pomodoro controls (reuse /control endpoints) ─────────────
function pomodoroApi(action) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/pomodoro/' + action);
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
Auth.request('POST', '/api/pomodoro/' + action, null, function () {
fetchState();
};
xhr.send();
});
}
function fetchPomodoro() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/pomodoro');
xhr.onload = function () {
if (xhr.status === 200) {
try {
state.pomodoro = JSON.parse(xhr.responseText);
renderPomodoro();
renderPomodoroCard();
} catch (_) {}
Auth.request('GET', '/api/pomodoro', null, function (data, status) {
if (status === 200) {
state.pomodoro = data || {};
renderPomodoro();
renderPomodoroCard();
}
};
xhr.send();
});
}
// Card pomodoro buttons (in sidebar card, not the panel)
@@ -1004,10 +1010,8 @@
}
function renderTimeCard() {
var now = new Date();
var h = String(now.getHours()).padStart(2, '0');
var m = String(now.getMinutes()).padStart(2, '0');
$('time-value').textContent = h + ':' + m;
var n = _tzNow();
$('time-value').textContent = _pad2(n.h) + ':' + _pad2(n.mi);
}
function renderWeatherCard() {
@@ -1026,8 +1030,7 @@
}
function renderCalendarCard() {
var now = new Date();
var nowStr = formatDateTimeLocal(now);
var nowStr = _nowLocalKey();
var upcoming = null;
for (var i = 0; i < state.events.length; i++) {
var ev = state.events[i];
@@ -1052,19 +1055,10 @@
return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13);
}
function formatDateTimeLocal(d) {
return d.getFullYear() +
String(d.getMonth() + 1).padStart(2, '0') +
String(d.getDate()).padStart(2, '0') + 'T' +
String(d.getHours()).padStart(2, '0') +
String(d.getMinutes()).padStart(2, '0') +
String(d.getSeconds()).padStart(2, '0');
}
function renderAlarmCard() {
var now = new Date();
var currentMinutes = now.getHours() * 60 + now.getMinutes();
var todayWeekday = String(now.getDay() === 0 ? 7 : now.getDay());
var n = _tzNow();
var currentMinutes = n.h * 60 + n.mi;
var todayWeekday = String(n.wd === 0 ? 7 : n.wd);
var upcoming = null;
var upcomingDiff = null;
@@ -1208,15 +1202,12 @@
}
function renderTimePanel() {
var now = new Date();
var n = _tzNow();
$('detailed-time').textContent =
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0');
_pad2(n.h) + ':' + _pad2(n.mi) + ':' + _pad2(n.s);
var days = ['日', '一', '二', '三', '四', '五', '六'];
$('date-str').textContent =
now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日 星期' + days[now.getDay()];
n.y + '年' + n.mo + '月' + n.d + '日 星期' + days[n.wd];
}
function renderWeatherPanel() {
@@ -1268,8 +1259,9 @@
return;
}
var html = '';
var nowHour = new Date().getHours();
var nowMin = new Date().getMinutes();
var n = _tzNow();
var nowHour = n.h;
var nowMin = n.mi;
hourly.forEach(function (h) {
var hh = parseInt(h.time.split(':')[0], 10);
var hm = parseInt(h.time.split(':')[1], 10);
@@ -1376,49 +1368,16 @@
function _renderWeatherUpdateInfo() {
var s = state.update_schedule || {};
var wUpdated = s.weather_updated_at || '';
// Compute next update times on the frontend from current local time
var now = new Date();
var m = now.getMinutes();
// Next weather: next :00 or :30
var nextW = new Date(now);
if (m < 30) { nextW.setMinutes(30, 0, 0); }
else { nextW.setHours(now.getHours() + 1, 0, 0, 0); }
// Next hourly: next :01
var nextH = new Date(now);
if (m < 1) { nextH.setMinutes(1, 0, 0); }
else { nextH.setHours(now.getHours() + 1, 1, 0, 0); }
// 后端返回的时间字符串均为账户时区本地时间,直接截取显示(勿再按 UTC 解析)
var fmt = function (d) {
if (!d) return '--';
// Server sends UTC datetime — parse as UTC, display in local timezone
var s = String(d).replace('T', ' ');
// Safari needs 'T' not space for ISO parsing
var utcDate = new Date(String(d).replace(' ', 'T') + 'Z');
if (!isNaN(utcDate.getTime())) {
var pad = function (n) { return String(n).padStart(2, '0'); };
return utcDate.getFullYear() + '-' + pad(utcDate.getMonth() + 1) + '-' + pad(utcDate.getDate()) +
' ' + pad(utcDate.getHours()) + ':' + pad(utcDate.getMinutes());
}
// Fallback to raw string
return s.substring(0, 16);
};
var fmtDate = function (d) {
return String(d.getFullYear()) + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' +
String(d.getMinutes()).padStart(2, '0');
return String(d).replace('T', ' ').substring(0, 16);
};
var html = '<div class="update-info">' +
'<div><span class="label">实况更新:</span> ' + fmt(wUpdated) +
' <span class="label">下次:</span> ' + fmtDate(nextW) + '</div>' +
'<div><span class="label">逐时下次:</span> ' + fmtDate(nextH) + '</div>' +
'<div><span class="label">实况更新:</span> ' + fmt(s.weather_updated_at) +
' <span class="label">下次:</span> ' + fmt(s.weather_next_update) + '</div>' +
'<div><span class="label">逐时下次:</span> ' + fmt(s.hourly_next_update) + '</div>' +
'</div>';
// Append after hourly list
@@ -1443,9 +1402,12 @@
if (!groups[dk]) groups[dk] = [];
groups[dk].push(ev);
});
var todayKey = new Date().toISOString().slice(0, 10).replace(/-/g, '');
var t = new Date(); t.setDate(t.getDate() + 1);
var tomorrowKey = t.toISOString().slice(0, 10).replace(/-/g, '');
var n = _tzNow();
var todayKey = String(n.y) + _pad2(n.mo) + _pad2(n.d);
// 明天:基于账户时区的年月日 +1 天(跨月/跨年交给 Date 处理)
var t = new Date();
t.setFullYear(n.y, n.mo - 1, n.d + 1);
var tomorrowKey = String(t.getFullYear()) + _pad2(t.getMonth() + 1) + _pad2(t.getDate());
var html = '';
Object.keys(groups).sort().forEach(function (dk) {
var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' :
@@ -1579,8 +1541,7 @@
}
// When weather panel is open, check if hourly needs refresh (past :01, stale)
if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') {
var now = new Date();
var min = now.getMinutes();
var min = _tzNow().mi;
var s = state.update_schedule || {};
// If it's past :05 and the hourly was likely last fetched before this hour
if (min > 5) {
+5 -12
View File
@@ -26,18 +26,11 @@
}
function fetchTheme(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/theme');
xhr.onload = function () {
if (xhr.status === 200) {
var d = {};
try { d = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
if (d.effective) apply(d.effective);
if (cb) cb(d);
}
};
xhr.onerror = function () { /* 网络失败保持当前主题 */ };
xhr.send();
// noRedirect: 登录页/未登录时 401 静默失败(保持默认主题),绝不跳转造成循环
Auth.request('GET', '/api/theme', null, function (d, status) {
if (status === 200 && d.effective) apply(d.effective);
if (cb) cb(d);
}, { noRedirect: true });
}
function initTheme() {
+160
View File
@@ -0,0 +1,160 @@
/* token.js — 双 Token 认证助手
*
* - access token:存 localStorage(短期),每次请求以 `Authorization: Bearer` 发送
* - refresh token:由服务端放在 httpOnly cookie 里,前端不可读;过期时
* POST /api/auth/refresh 换取新 access(服务端同时轮换 refresh cookie)
* - 401 处理:并发 401 共享一次刷新(single-flight),刷新成功后重试一次原请求;
* 刷新失败则清空本地 token 并跳转登录页
* - SSE(EventSource 无法带自定义 header)通过一次性 ticket 建立,见 display.js
*/
(function (global) {
'use strict';
var KEY = 'dashboard_access_token';
function getAccess() {
try { return localStorage.getItem(KEY) || ''; } catch (e) { return ''; }
}
function setAccess(t) {
try {
if (t) { localStorage.setItem(KEY, t); } else { localStorage.removeItem(KEY); }
} catch (e) {}
}
function clear() { setAccess(''); }
function _detectView() {
var p = location.pathname;
if (p.indexOf('/control') === 0 || p.indexOf('/mfa-setup') === 0) return 'control';
return 'display';
}
function loginRedirect(view) {
clear();
var next = encodeURIComponent(location.pathname + location.search);
location.href = '/login?view=' + (view || _detectView()) + '&next=' + next;
}
// 并发 401 共享同一次刷新(避免同时打多个 refresh 导致 token 轮换竞争)
var _refreshing = null;
function refresh() {
if (!_refreshing) {
_refreshing = new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/refresh');
xhr.onload = function () {
if (xhr.status === 200) {
try {
var d = JSON.parse(xhr.responseText || '{}');
if (d.access_token) { setAccess(d.access_token); resolve(d.access_token); return; }
} catch (e) {}
}
reject(new Error('refresh-failed'));
};
xhr.onerror = function () { reject(new Error('network')); };
xhr.send();
});
_refreshing.then(function () { _refreshing = null; },
function () { _refreshing = null; });
}
return _refreshing;
}
/**
* 统一请求封装(XHR,回调风格)
* request(method, url, data, callback, opts)
* - data: 对象或 FormData;null/undefined 表示无 body
* - opts.view: 跳转登录页时使用的 view(默认按当前路径推断)
* - callback(res, status): 收到响应(含 401 重试后的最终结果)
*/
function request(method, url, data, callback, opts) {
opts = opts || {};
var view = opts.view || _detectView();
function doSend(access, retried) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if (access) xhr.setRequestHeader('Authorization', 'Bearer ' + access);
if (data && !(data instanceof FormData)) {
xhr.setRequestHeader('Content-Type', 'application/json');
}
xhr.onload = function () {
var res = {};
try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
if (xhr.status === 401) {
if (!retried) {
var fail = function () {
if (opts.noRedirect) { if (callback) callback(res, xhr.status); return; }
// 多 tab 竞态:另一个 tab 可能刚轮换过共享的 refresh cookie,
// 短暂延迟后用新 cookie 再试一次,仍失败才判定会话失效。
setTimeout(function () {
refresh().then(function () {
doSend(getAccess(), true);
}, function () {
loginRedirect(view);
});
}, 300);
};
refresh().then(function () {
doSend(getAccess(), true);
}, fail);
return;
}
// 刷新后重试仍 401 → 会话确实失效(如已被踢),跳转登录页
if (!opts.noRedirect) {
loginRedirect(view);
return;
}
if (callback) callback(res, xhr.status);
return;
}
if (callback) callback(res, xhr.status);
};
xhr.onerror = function () { if (callback) callback({}, 0); };
if (data instanceof FormData) xhr.send(data);
else if (data) xhr.send(JSON.stringify(data));
else xhr.send();
}
doSend(getAccess(), false);
}
/** Promise 风格的 fetch 封装(带 Authorization,用于登录页/Passkey 桥等) */
function fetchWithAuth(url, options) {
options = options || {};
var access = getAccess();
var headers = Object.assign({}, options.headers || {});
if (access && !headers['Authorization']) headers['Authorization'] = 'Bearer ' + access;
return fetch(url, Object.assign({}, options, {
headers: headers,
credentials: 'same-origin',
}));
}
/** 主动登出:清本地 access + 通知服务端吊销 refresh cookie(同时吊销本 access) */
function logout(cb) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
var access = getAccess();
if (access) xhr.setRequestHeader('Authorization', 'Bearer ' + access);
xhr.onload = function () {
clear();
if (cb) cb();
};
xhr.onerror = function () {
clear();
if (cb) cb();
};
xhr.send();
}
global.Auth = {
getAccess: getAccess,
setAccess: setAccess,
clear: clear,
refresh: refresh,
request: request,
fetchWithAuth: fetchWithAuth,
logout: logout,
loginRedirect: loginRedirect,
};
})(window);
+5 -1
View File
@@ -258,7 +258,9 @@
<button type="button" id="user-add-admin" class="btn-primary">👨‍💼 管理员代注册</button>
<button type="button" id="user-add-temp" class="btn-secondary">🕐 创建临时账户(用户填写信息模式)</button>
<button type="button" id="user-refresh-btn" class="btn-secondary">刷新列表</button>
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
</div>
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
<div id="pending-requests" style="margin-bottom:16px;"></div>
<div id="user-list"></div>
</div>
@@ -308,8 +310,9 @@
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button type="button" id="kick-all-btn" class="btn-danger">踢掉所有显示端</button>
<button type="button" id="kick-all-btn" class="btn-danger">踢掉当前账号的所有设备</button>
</div>
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);">包含当前控制端与所有显示端,全部立即下线。</div>
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
<div class="section">
@@ -388,6 +391,7 @@
</main>
</div>
<script src="/static/js/token.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/control.js"></script>
+1
View File
@@ -242,6 +242,7 @@
<audio id="alarm-audio" loop preload="auto" style="display:none"></audio>
</div>
<script src="/static/js/token.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/display.js"></script>
</body>
+23 -15
View File
@@ -75,6 +75,7 @@
</div>
</div>
<script src="/static/js/token.js"></script>
<script>
(function () {
'use strict';
@@ -149,21 +150,28 @@
// SSE: listen for control commands (force_refresh / mode_change / custom_notification)
function connectSSE() {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
location.reload();
} else if (msg.type === 'custom_notification') {
showCustomNotification(msg.data);
}
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
// EventSource 不能带 Authorization header → 先换一次性 ticket
Auth.request('POST', '/api/events/ticket', null, function (res, status) {
if (status !== 201 || !res.ticket) {
setTimeout(connectSSE, 5000);
return;
}
var es = new EventSource('/api/events?ticket=' + encodeURIComponent(res.ticket));
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
location.reload();
} else if (msg.type === 'custom_notification') {
showCustomNotification(msg.data);
}
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
});
}
connectSSE();
})();
+10 -34
View File
@@ -155,6 +155,7 @@
.theme-light .alt-btn:active { background: #e4e9f0; }
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
@@ -227,17 +228,15 @@
}
// ─── Auth status check ──────────────────
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/status');
xhr.onload = function () {
if (xhr.status === 200) {
var status = JSON.parse(xhr.responseText);
// 携带本地 access token 检查(token 有效则直接放行,避免重复登录)
Auth.fetchWithAuth('/api/auth/status')
.then(function (r) { return r.json(); })
.then(function (status) {
if (status.authenticated) {
_redirect(redirect);
}
}
};
xhr.send();
})
.catch(function () {});
// ─── TOTP dots ──────────────────────────
function updateDots(ok) {
@@ -259,6 +258,7 @@
xhr.onload = function () {
if (xhr.status === 200) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.access_token) Auth.setAccess(res.access_token);
if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup');
} else {
@@ -317,6 +317,7 @@
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
msgEl.textContent = '✅ 登录成功!';
setTimeout(function () { _redirect(redirect); }, 300);
} else {
@@ -368,6 +369,7 @@
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
_redirect(redirect);
} else {
msgEl.textContent = res.error || '恢复码错误';
@@ -383,32 +385,6 @@
}
});
// ─── SSE background listener ────────────
(function () {
function connectSSE() {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
location.href = '/';
} else if (msg.type === 'kick_all') {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () { location.reload(); };
xhr.send();
}
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
// ─── Helpers ────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) {
+7 -6
View File
@@ -172,6 +172,7 @@
}
.divider::before, .divider::after { background: var(--divider); }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
@@ -247,12 +248,10 @@
});
function checkStatus(callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/status');
xhr.onload = function () {
if (xhr.status === 200) callback(JSON.parse(xhr.responseText));
};
xhr.send();
Auth.fetchWithAuth('/api/auth/status')
.then(function (r) { return r.json(); })
.then(function (data) { callback(data); })
.catch(function () { callback({}); });
}
function showLoginForm(mfaConfigured) {
@@ -280,6 +279,7 @@
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup');
} else {
@@ -358,6 +358,7 @@
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
statusEl.textContent = '✅ 登录成功!';
_redirect(redirect);
} else {
+37 -72
View File
@@ -141,6 +141,7 @@
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
.theme-light .badge-pending { background: #fff3e0; color: #e65100; }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
@@ -207,29 +208,21 @@
// ─── Check MFA status ──────────────────
function checkMFAStatus() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/mfa/status');
xhr.onload = function () {
if (xhr.status === 200) {
var s = JSON.parse(xhr.responseText);
// Temp accounts must configure ONE login method to activate the account.
if (s.is_temp_account) {
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
document.querySelector('.subtitle').textContent =
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
}
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
if (s.recovery_codes_remaining > 0) {
showRecoveryCodes();
}
updateFinishBtn();
} else if (xhr.status === 401) {
// Not logged in — redirect to login
location.href = '/login?view=control&next=/mfa-setup';
Auth.request('GET', '/api/auth/mfa/status', null, function (s, status) {
if (status !== 200) return;
// Temp accounts must configure ONE login method to activate the account.
if (s.is_temp_account) {
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
document.querySelector('.subtitle').textContent =
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
}
};
xhr.send();
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
if (s.recovery_codes_remaining > 0) {
showRecoveryCodes();
}
updateFinishBtn();
});
}
checkMFAStatus();
@@ -251,33 +244,26 @@
btn.disabled = true;
statusEl.textContent = '正在准备注册...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/register/begin');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) {
Auth.request('POST', '/api/auth/webauthn/register/begin', null, function (options, st) {
if (st !== 200) {
statusEl.textContent = '❌ 获取注册参数失败';
btn.disabled = false;
return;
}
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options)
.then(function (credential) {
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
var verify = new XMLHttpRequest();
verify.open('POST', '/api/auth/webauthn/register/complete');
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
Auth.request('POST', '/api/auth/webauthn/register/complete', { credential: credential }, function (res, st2) {
if (st2 === 200) {
statusEl.textContent = '✅ Passkey 注册成功!';
passkeyDone = true;
updatePasskeyUI(true);
checkRecoveryAndFinish();
if (res.finalized) {
if (res.access_token) Auth.setAccess(res.access_token);
statusEl.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
@@ -285,15 +271,13 @@
statusEl.textContent = '❌ ' + (res.error || '注册失败');
btn.disabled = false;
}
};
verify.send(JSON.stringify({ credential: credential }));
});
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false;
});
};
xhr.send();
});
});
// ─── TOTP ──────────────────────────────
@@ -318,16 +302,11 @@
document.getElementById('setup-totp-btn').textContent = '取消';
// Request TOTP setup from server
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/totp/setup');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) return;
var data = JSON.parse(xhr.responseText);
Auth.request('POST', '/api/auth/totp/setup', null, function (data, status) {
if (status !== 200) return;
document.getElementById('totp-qr-wrap').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR Code">';
document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret;
};
xhr.send();
});
});
document.getElementById('totp-verify-btn').addEventListener('click', function () {
@@ -335,12 +314,8 @@
var msg = document.getElementById('totp-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/totp/verify');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
Auth.request('POST', '/api/auth/totp/verify', { code: code }, function (res, st) {
if (st === 200) {
msg.className = 'msg success';
msg.textContent = '✅ TOTP 已启用!';
totpDone = true;
@@ -354,14 +329,14 @@
}
checkRecoveryAndFinish();
if (res.finalized) {
if (res.access_token) Auth.setAccess(res.access_token);
msg.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
msg.textContent = res.error || '验证失败';
}
};
xhr.send(JSON.stringify({ code: code }));
});
});
// ─── Recovery codes ────────────────────
@@ -373,17 +348,11 @@
}
function showRecoveryCodes() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/recovery-codes');
xhr.onload = function () {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
if (data.codes && data.codes.length > 0) {
showRecoveryCodesList(data.codes);
}
Auth.request('GET', '/api/auth/recovery-codes', null, function (data, status) {
if (status === 200 && data.codes && data.codes.length > 0) {
showRecoveryCodesList(data.codes);
}
};
xhr.send();
});
}
function checkRecoveryAndFinish() {
@@ -417,18 +386,14 @@
document.getElementById('finish-btn').addEventListener('click', function () {
var msg = document.getElementById('finish-msg');
msg.textContent = '正在完成设置...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/mfa/complete');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status === 200) {
Auth.request('POST', '/api/auth/mfa/complete', null, function (res, status) {
if (status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
location.href = '/';
} else {
var res = JSON.parse(xhr.responseText || '{}');
msg.textContent = res.error || '完成失败,请重试';
}
};
xhr.send();
});
});
// ─── Helpers ────────────────────────────