feat: 安全设置展示有效登录会话,支持剔除其它设备

按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 01:48:52 +08:00
parent 2ccfb5f330
commit 3a7bfe59df
16 changed files with 808 additions and 28 deletions

View File

@@ -1346,6 +1346,47 @@ export async function apiChangePassword(oldPassword: string, newPassword: string
return res.json();
}
/** 当前用户仍有效的登录会话(安全设置 SSR / 客户端) */
export interface LoginDevice {
id: number;
ip: string;
device_kind: "computer" | "mobile" | "tablet";
device_label: string;
browser: string;
current: boolean;
created_at: string;
}
export interface LoginDevicesResponse {
devices: LoginDevice[];
}
export async function fetchLoginDevices(
cookieHeader?: string,
viewer?: { userAgent?: string; clientIP?: string }
): Promise<LoginDevice[]> {
const headers: Record<string, string> = {};
if (cookieHeader) headers.Cookie = cookieHeader;
if (viewer?.userAgent) headers["X-J13-Client-UA"] = viewer.userAgent;
if (viewer?.clientIP) headers["X-J13-Client-IP"] = viewer.clientIP;
const res = await fetch(`${API_BASE}/api/me/login-devices`, ssrInit(headers));
if (res.status === 401) return [];
if (!res.ok) throw new Error("获取登录设备失败");
const data = (await res.json()) as LoginDevicesResponse;
return data.devices ?? [];
}
/** 剔除其它登录设备,该设备需重新输入密码登录 */
export async function apiRevokeLoginDevice(
id: number
): Promise<{ ok?: boolean; error?: string }> {
const res = await fetchWithRefresh(`/api/me/login-devices/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
// 更新当前用户资料(昵称、头像、邮箱、签名)
export async function apiUpdateProfile(data: {
nickname: string;

View File

@@ -26,6 +26,19 @@ export function authCookieHeader(store: CookieReader): string {
return token ? `${TOKEN_COOKIE}=${token.value}` : "";
}
/**
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」并校准 IP/UA。
* 其它 SSR 接口不要用这个,避免把 refresh token 带到无关后端请求。
*/
export function sessionCookieHeader(store: CookieReader): string {
const parts: string[] = [];
const token = store.get(TOKEN_COOKIE);
if (token) parts.push(`${TOKEN_COOKIE}=${token.value}`);
const refresh = store.get(REFRESH_COOKIE);
if (refresh) parts.push(`${REFRESH_COOKIE}=${refresh.value}`);
return parts.join("; ");
}
/**
* 帖子详情 SSR:access token + 密码隐藏块解锁 cookie。
* 解锁 cookie 为 HttpOnly,必须随 SSR 转发,否则 refresh 后密码块会再次锁上。

View File

@@ -47,3 +47,10 @@ export function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** 完整日期时间 YYYY-MM-DD HH:mm:ss(本地时区) */
export function formatDateTime(dateStr: string): string {
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return "";
return `${formatDate(dateStr)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}