Files
jiang13-bbs/frontend/app/u/[id]/LoginDevices.tsx
freefire 3a7bfe59df feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

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

144 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>
{d.current ? (
<p className="mt-1 text-[12px] font-semibold" style={{ color: "var(--ok)" }}>
当前在线
</p>
) : (
<button
type="button"
className="mt-1 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>
);
}