feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 后密码块会再次锁上。
|
||||
|
||||
@@ -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())}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user