Files
jiang13-bbs/frontend/app/u/[id]/LoginDevices.tsx
freefire b3a4b8bb03 feat: 安全设置其它有效会话也标在线
本机仍为「当前在线」,Chrome/Edge 等同时登录的其它会话显示「在线」并可剔除。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 01:59:49 +08:00

143 lines
4.9 KiB
TypeScript
Raw 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.
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Monitor, Smartphone, Tablet } from "lucide-react";
import ConfirmDialog from "@/components/ConfirmDialog";
import { apiRevokeLoginDevice, type LoginDevice } from "@/lib/api";
import { formatDateTime } from "@/lib/format";
function DeviceIcon({ kind }: { kind: LoginDevice["device_kind"] }) {
const props = { size: 20, strokeWidth: 1.75, "aria-hidden": true as const };
if (kind === "mobile") return <Smartphone {...props} />;
if (kind === "tablet") return <Tablet {...props} />;
return <Monitor {...props} />;
}
/**
* 安全设置:仍有效的登录会话。
* 本机标「当前在线」;其它仍有效会话标「在线」,并可剔除。
*/
export default function LoginDevices({ devices: initial }: { devices: LoginDevice[] }) {
const router = useRouter();
const [devices, setDevices] = useState(initial);
const [target, setTarget] = useState<LoginDevice | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setDevices(initial);
}, [initial]);
async function confirmKick() {
if (!target) return;
setBusy(true);
setError("");
try {
const res = await apiRevokeLoginDevice(target.id);
if (res.error) {
setError(res.error);
return;
}
setDevices((prev) => prev.filter((d) => d.id !== target.id));
setTarget(null);
router.refresh();
} catch {
setError("网络异常,请稍后重试");
} finally {
setBusy(false);
}
}
return (
<div className="mt-8">
<div className="mb-4">
<h2 className="text-[18px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
最近登录的设备
</h2>
<p className="meta mt-1 text-[13px]">
下列为仍有效的登录会话。若出现无法识别的设备,请立即剔除或修改密码
</p>
</div>
<div className="panel overflow-hidden">
{devices.length === 0 ? (
<div className="px-5 py-12 text-center">
<Monitor size={22} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
<p className="meta text-[12.5px]">暂无登录记录</p>
</div>
) : (
<ul>
{devices.map((d, i) => {
const ip = d.ip || "未知 IP";
return (
<li
key={d.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: i === 0 ? undefined : "1px solid var(--line)" }}
>
<span
className="mt-0.5 w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--tint)", color: "var(--ink-2)" }}
aria-hidden
>
<DeviceIcon kind={d.device_kind} />
</span>
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-semibold leading-snug truncate" style={{ color: "var(--ink)" }}>
{d.device_label}
</p>
<p className="meta text-[12px] mt-0.5 truncate tabular-nums">
IP {ip} · {d.browser}
</p>
</div>
<div className="shrink-0 text-right pl-2">
<p className="text-[12px] tabular-nums leading-snug" style={{ color: "var(--ink-3)" }}>
{formatDateTime(d.created_at)}
</p>
<p className="mt-1 text-[12px] font-semibold" style={{ color: "var(--ok)" }}>
{d.current ? "当前在线" : "在线"}
</p>
{!d.current && (
<button
type="button"
className="mt-0.5 text-[12px] font-semibold hover:underline"
style={{ color: "var(--danger)" }}
onClick={() => {
setError("");
setTarget(d);
}}
>
剔除
</button>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
<ConfirmDialog
open={!!target}
title="剔除这台设备?"
message={
error
? `剔除失败:${error}`
: "剔除后,该设备上的登录会立即失效,需要重新输入密码才能登录。"
}
confirmLabel="确认剔除"
danger
busy={busy}
onConfirm={() => void confirmKick()}
onCancel={() => {
if (!busy) {
setTarget(null);
setError("");
}
}}
/>
</div>
);
}