From d3b03cecf295c660123841dae8f1fc5f288d849a Mon Sep 17 00:00:00 2001 From: feie9456 Date: Fri, 17 Apr 2026 12:58:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B4=A6=E5=8F=B7=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/activity/page.tsx | 3 +- app/api/account/login/route.ts | 44 ++++ app/api/account/logout/route.ts | 11 + app/api/account/register/route.ts | 45 ++++ app/api/family/bind/route.ts | 12 +- app/api/family/messages/route.ts | 12 +- app/api/family/session/route.ts | 44 +--- app/api/push/subscribe/route.ts | 12 +- app/bind/page.tsx | 47 +++- app/devices/[deviceUuid]/page.tsx | 3 +- app/devices/page.tsx | 3 +- app/login/page.tsx | 79 +++++++ app/messages/[messageId]/page.tsx | 3 +- app/messages/page.tsx | 3 +- app/page.tsx | 3 +- components/account-auth-form.tsx | 189 +++++++++++++++ components/activity-chart.tsx | 2 +- components/dashboard-actions.tsx | 34 +-- components/panel-shell.tsx | 14 +- components/scan-client.tsx | 35 +-- lib/monitor-data.ts | 98 ++++++++ lib/page-auth.ts | 4 +- lib/session.ts | 177 ++++++++++++-- package-lock.json | 219 +++++++++--------- package.json | 1 + .../migration.sql | 59 +++++ prisma/schema.prisma | 29 ++- 27 files changed, 931 insertions(+), 254 deletions(-) create mode 100644 app/api/account/login/route.ts create mode 100644 app/api/account/logout/route.ts create mode 100644 app/api/account/register/route.ts create mode 100644 app/login/page.tsx create mode 100644 components/account-auth-form.tsx create mode 100644 prisma/migrations/20260417045149_account_auth_multi_device/migration.sql diff --git a/app/activity/page.tsx b/app/activity/page.tsx index 347dddf..1987598 100644 --- a/app/activity/page.tsx +++ b/app/activity/page.tsx @@ -9,6 +9,7 @@ import { truncateText, } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -38,7 +39,7 @@ export default async function ActivityPage() { currentPath="/activity" title="陪伴动态" description="了解长辈最近的使用情况、聊天内容和智能服务记录。" - caregiverToken={caregiver.sessionToken} + caregiverLabel={getCaregiverDisplayName(caregiver)} > {usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts new file mode 100644 index 0000000..6e2dc24 --- /dev/null +++ b/app/api/account/login/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; + +import { + loginCaregiverAccount, + sanitizeCaregiverRedirectPath, +} from "@/lib/session"; + +export async function POST(request: Request) { + const body = (await request.json().catch(() => null)) as + | { + username?: string; + password?: string; + redirectTo?: string; + } + | null; + + if (!body?.username || typeof body.username !== "string") { + return NextResponse.json({ error: "请先输入用户名。" }, { status: 400 }); + } + + if (typeof body.password !== "string" || body.password.length === 0) { + return NextResponse.json({ error: "请先输入密码。" }, { status: 400 }); + } + + try { + await loginCaregiverAccount({ + username: body.username, + password: body.password, + userAgent: request.headers.get("user-agent"), + }); + + return NextResponse.json({ + ok: true, + redirectTo: sanitizeCaregiverRedirectPath(body.redirectTo), + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "登录失败,请稍后再试。", + }, + { status: 400 }, + ); + } +} \ No newline at end of file diff --git a/app/api/account/logout/route.ts b/app/api/account/logout/route.ts new file mode 100644 index 0000000..78511d8 --- /dev/null +++ b/app/api/account/logout/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +import { clearCurrentCaregiverSession } from "@/lib/session"; + +export async function POST(request: Request) { + await clearCurrentCaregiverSession(); + + return NextResponse.redirect(new URL("/login", request.url), { + status: 303, + }); +} \ No newline at end of file diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts new file mode 100644 index 0000000..81e9aad --- /dev/null +++ b/app/api/account/register/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { + registerCaregiverAccount, + sanitizeCaregiverRedirectPath, +} from "@/lib/session"; + +export async function POST(request: Request) { + const body = (await request.json().catch(() => null)) as + | { + username?: string; + password?: string; + redirectTo?: string; + } + | null; + + if (!body?.username || typeof body.username !== "string") { + return NextResponse.json({ error: "请先输入用户名。" }, { status: 400 }); + } + + if (typeof body.password !== "string" || body.password.length === 0) { + return NextResponse.json({ error: "请先输入密码。" }, { status: 400 }); + } + + try { + await registerCaregiverAccount({ + username: body.username, + password: body.password, + userAgent: request.headers.get("user-agent"), + }); + + return NextResponse.json({ + ok: true, + redirectTo: sanitizeCaregiverRedirectPath(body.redirectTo), + }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "创建账号失败,请稍后再试。", + }, + { status: 400 }, + ); + } +} \ No newline at end of file diff --git a/app/api/family/bind/route.ts b/app/api/family/bind/route.ts index a33e05c..e877982 100644 --- a/app/api/family/bind/route.ts +++ b/app/api/family/bind/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data"; -import { getOrCreateCaregiverSession } from "@/lib/session"; +import { getCaregiverSession } from "@/lib/session"; export async function POST(request: Request) { const body = (await request.json().catch(() => null)) as @@ -18,7 +18,15 @@ export async function POST(request: Request) { } try { - const caregiver = await getOrCreateCaregiverSession(); + const caregiver = await getCaregiverSession(); + + if (!caregiver) { + return NextResponse.json( + { error: "请先登录账号后再绑定设备。" }, + { status: 401 }, + ); + } + const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid); return NextResponse.json({ diff --git a/app/api/family/messages/route.ts b/app/api/family/messages/route.ts index d8ef406..ba802be 100644 --- a/app/api/family/messages/route.ts +++ b/app/api/family/messages/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data"; -import { getOrCreateCaregiverSession } from "@/lib/session"; +import { getCaregiverSession } from "@/lib/session"; export async function POST(request: Request) { const body = (await request.json().catch(() => null)) as @@ -27,7 +27,15 @@ export async function POST(request: Request) { } try { - const caregiver = await getOrCreateCaregiverSession(); + const caregiver = await getCaregiverSession(); + + if (!caregiver) { + return NextResponse.json( + { error: "请先登录账号后再发送留言。" }, + { status: 401 }, + ); + } + const message = await createFamilyMessageFromCaregiver({ caregiverId: caregiver.id, rawDeviceValue: body.deviceUuid, diff --git a/app/api/family/session/route.ts b/app/api/family/session/route.ts index 6abec88..e91b080 100644 --- a/app/api/family/session/route.ts +++ b/app/api/family/session/route.ts @@ -1,49 +1,19 @@ import { type NextRequest, NextResponse } from "next/server"; import { - CAREGIVER_SESSION_COOKIE, - createCaregiverSessionCookieValue, + buildCaregiverLoginPath, getCaregiverSession, + sanitizeCaregiverRedirectPath, } from "@/lib/session"; -import { prisma } from "@/lib/prisma"; - -function normalizeRedirectPath(redirectTo?: string | null) { - if (!redirectTo || !redirectTo.startsWith("/") || redirectTo.startsWith("//")) { - return "/"; - } - - return redirectTo; -} export async function GET(request: NextRequest) { - const redirectTo = normalizeRedirectPath( + const redirectTo = sanitizeCaregiverRedirectPath( request.nextUrl.searchParams.get("redirectTo"), ); - const existingCaregiver = await getCaregiverSession(); - const response = new NextResponse(null, { + const caregiver = await getCaregiverSession(); + const location = caregiver ? redirectTo : buildCaregiverLoginPath(redirectTo); + + return NextResponse.redirect(new URL(location, request.url), { status: 307, - headers: { - Location: redirectTo, - }, }); - - if (existingCaregiver) { - return response; - } - - const { sessionToken, options } = createCaregiverSessionCookieValue(); - - await prisma.caregiverAccount.upsert({ - where: { sessionToken }, - update: {}, - create: { sessionToken }, - }); - - response.cookies.set({ - name: CAREGIVER_SESSION_COOKIE, - value: sessionToken, - ...options, - }); - - return response; } \ No newline at end of file diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts index f3d46e4..53d70c8 100644 --- a/app/api/push/subscribe/route.ts +++ b/app/api/push/subscribe/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { getOrCreateCaregiverSession } from "@/lib/session"; +import { getCaregiverSession } from "@/lib/session"; import { savePushSubscription } from "@/lib/push"; export async function POST(request: Request) { @@ -50,7 +50,15 @@ export async function POST(request: Request) { }; try { - const caregiver = await getOrCreateCaregiverSession(); + const caregiver = await getCaregiverSession(); + + if (!caregiver) { + return NextResponse.json( + { error: "请先登录账号后再开启消息提醒。" }, + { status: 401 }, + ); + } + await savePushSubscription({ caregiverId: caregiver.id, subscription, diff --git a/app/bind/page.tsx b/app/bind/page.tsx index 34972e8..cf87ef5 100644 --- a/app/bind/page.tsx +++ b/app/bind/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { bindDeviceToCaregiver } from "@/lib/monitor-data"; import { - buildCaregiverSessionBootstrapPath, + buildCaregiverLoginPath, getCaregiverSession, } from "@/lib/session"; @@ -58,13 +58,54 @@ export default async function BindPage({ searchParams }: BindPageProps) { if (!caregiver) { redirect( - buildCaregiverSessionBootstrapPath( + buildCaregiverLoginPath( `/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`, ), ); } - const device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue); + let device: Awaited> | null = null; + let bindError: string | null = null; + + try { + device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue); + } catch (error) { + bindError = + error instanceof Error ? error.message : "绑定失败,请稍后再试。"; + } + + if (!device) { + return ( +
+
+

+ 暂时没连上 +

+

+ 这次还没能完成设备连接 +

+

+ {bindError || "请重新扫码,或请长辈重新打开设备连接页面后再试一次。"} +

+ +
+ + 重新扫码 + + + 返回设备页 + +
+
+
+ ); + } return (
diff --git a/app/devices/[deviceUuid]/page.tsx b/app/devices/[deviceUuid]/page.tsx index f56222e..8fed237 100644 --- a/app/devices/[deviceUuid]/page.tsx +++ b/app/devices/[deviceUuid]/page.tsx @@ -14,6 +14,7 @@ import { truncateText, } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -42,7 +43,7 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps currentPath="/devices" title={getDeviceName(binding.elderDevice.displayName)} description="给长辈写问候、查看留言和最近的使用动态。" - caregiverToken={caregiver.sessionToken} + caregiverLabel={getCaregiverDisplayName(caregiver)} >
diff --git a/app/devices/page.tsx b/app/devices/page.tsx index c854991..fdccf07 100644 --- a/app/devices/page.tsx +++ b/app/devices/page.tsx @@ -6,6 +6,7 @@ import { PwaControls } from "@/components/pwa-controls"; import { getCaregiverDevices } from "@/lib/caregiver-panel"; import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -18,7 +19,7 @@ export default async function DevicesPage() { currentPath="/devices" title="我的设备" description="管理已连接的长辈设备,点击进入设备详情查看留言和使用动态。" - caregiverToken={caregiver.sessionToken} + caregiverLabel={getCaregiverDisplayName(caregiver)} actions={} >
diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..4301136 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,79 @@ +import { redirect } from "next/navigation"; + +import { AccountAuthForm } from "@/components/account-auth-form"; +import { + getCaregiverSession, + sanitizeCaregiverRedirectPath, +} from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +type LoginPageProps = { + searchParams: Promise>; +}; + +export default async function LoginPage({ searchParams }: LoginPageProps) { + const caregiver = await getCaregiverSession(); + const params = await searchParams; + const redirectTo = sanitizeCaregiverRedirectPath( + typeof params.redirectTo === "string" ? params.redirectTo : null, + ); + + if (caregiver) { + redirect(redirectTo); + } + + return ( +
+
+
+
+
+ +

+ 家人连线 +

+

+ 用同一个账号, +
+ 在每一台家属设备上继续守护 +

+

+ 现在开始,设备连接、留言记录和消息提醒都按账号归属。您在手机、平板或电脑上登录同一个账号,长辈发来的新留言都会一起送达。 +

+ +
+ {[ + "绑定过的长辈设备会跟随账号保留,不再分散到不同浏览器会话。", + "一台设备开了提醒,另一台设备也登录同一个账号时,同样可以独立接收推送。", + "登录后会自动回到刚才的页面,扫码和绑定流程不用重新开始。", + ].map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+
+

+ 账号登录 +

+

+ 先登录,再继续刚才的操作 +

+

+ 如果您刚刚在扫码或添加设备,登录完成后会自动回到原来的页面,不需要重新输入。 +

+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/messages/[messageId]/page.tsx b/app/messages/[messageId]/page.tsx index 73d9896..24913b9 100644 --- a/app/messages/[messageId]/page.tsx +++ b/app/messages/[messageId]/page.tsx @@ -11,6 +11,7 @@ import { getMessageStatusLabel, } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -38,7 +39,7 @@ export default async function MessageDetailPage({ params }: MessageDetailPagePro currentPath="/messages" title={`留言 #${message.publicId}`} description="查看这条留言的完整内容和设备信息。" - caregiverToken={caregiver.sessionToken} + caregiverLabel={getCaregiverDisplayName(caregiver)} >
diff --git a/app/messages/page.tsx b/app/messages/page.tsx index 0fbd788..e32579a 100644 --- a/app/messages/page.tsx +++ b/app/messages/page.tsx @@ -13,6 +13,7 @@ import { truncateText, } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -33,7 +34,7 @@ export default async function MessagesPage() { currentPath="/messages" title="留言板" description="长辈捆来的话和您发出的问候,都在这里。" - caregiverToken={caregiver.sessionToken} + caregiverLabel={getCaregiverDisplayName(caregiver)} actions={} >
diff --git a/app/page.tsx b/app/page.tsx index a34e8e2..52001b4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,7 @@ import { truncateText, } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -38,7 +39,7 @@ export default async function Home() { currentPath="/" title="今日概览" description="随时了解长辈的近况,留言和动态一目了然。" - caregiverToken={dashboard.caregiver.sessionToken} + caregiverLabel={getCaregiverDisplayName(dashboard.caregiver)} actions={} >
diff --git a/components/account-auth-form.tsx b/components/account-auth-form.tsx new file mode 100644 index 0000000..cb53d04 --- /dev/null +++ b/components/account-auth-form.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { startTransition, useState } from "react"; + +type AccountAuthFormProps = { + redirectTo: string; +}; + +type AuthMode = "login" | "register"; + +export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) { + const router = useRouter(); + const [mode, setMode] = useState("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (!username.trim()) { + setNotice("请先输入用户名。"); + return; + } + + if (!password) { + setNotice("请先输入密码。"); + return; + } + + if (mode === "register" && password !== confirmPassword) { + setNotice("两次输入的密码还不一致,请再检查一次。"); + return; + } + + setSubmitting(true); + setNotice(mode === "login" ? "正在登录账号……" : "正在创建账号……"); + + try { + const response = await fetch( + mode === "login" ? "/api/account/login" : "/api/account/register", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: username.trim(), + password, + redirectTo, + }), + }, + ); + + const payload = (await response.json().catch(() => null)) as + | { error?: string; redirectTo?: string } + | null; + + if (!response.ok) { + throw new Error( + payload?.error || + (mode === "login" + ? "登录失败,请稍后再试。" + : "创建账号失败,请稍后再试。"), + ); + } + + setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……"); + startTransition(() => { + router.replace(payload?.redirectTo || redirectTo); + router.refresh(); + }); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "操作失败,请稍后再试。", + ); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ {[ + { id: "login", label: "已有账号,直接登录" }, + { id: "register", label: "第一次使用,创建账号" }, + ].map((item) => { + const active = mode === item.id; + + return ( + + ); + })} +
+ +
+
+ + setUsername(event.target.value)} + autoComplete="username" + placeholder="例如:family_guardian" + className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white" + /> +

+ 建议使用 3 到 24 位字母、数字、下划线或横线,方便全家统一登录。 +

+
+ +
+ + setPassword(event.target.value)} + autoComplete={mode === "login" ? "current-password" : "new-password"} + placeholder="请输入密码" + className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white" + /> +

+ 密码至少 8 位。同一账号可以在多台家属设备登录,消息提醒会同步到所有已登录设备。 +

+
+ + {mode === "register" ? ( +
+ + setConfirmPassword(event.target.value)} + autoComplete="new-password" + placeholder="再次输入密码" + className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white" + /> +
+ ) : null} + + +
+ + {notice ? ( +

+ {notice} +

+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/components/activity-chart.tsx b/components/activity-chart.tsx index 6df1e90..2e35bb4 100644 --- a/components/activity-chart.tsx +++ b/components/activity-chart.tsx @@ -50,7 +50,7 @@ export function ActivityChart({ data, color = "var(--copper)" }: ActivityChartPr fontSize: 13, }} labelStyle={{ color: "#241b14", fontWeight: 600 }} - formatter={(value: number) => [`${value} 次`, "次数"]} + formatter={(value) => [`${Number(value ?? 0)} 次`, "次数"]} /> null)) as - | { error?: string } - | null; - - if (!response.ok) { - throw new Error(payload?.error || "连接失败,请稍后再试。"); - } - - setNotice("连接成功!现在可以查看长辈的留言和动态了。\n"); - setRawCode(""); - startTransition(() => { - router.refresh(); - }); - } catch (error) { - setNotice( - error instanceof Error ? error.message : "连接失败,请稍后再试。", - ); - } finally { - setSubmitting(false); - } + startTransition(() => { + router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`); + }); } return ( diff --git a/components/panel-shell.tsx b/components/panel-shell.tsx index e2767af..dbabd77 100644 --- a/components/panel-shell.tsx +++ b/components/panel-shell.tsx @@ -22,7 +22,7 @@ type PanelShellProps = { currentPath: string; title: string; description: string; - caregiverToken: string; + caregiverLabel: string; children: ReactNode; actions?: ReactNode; eyebrow?: string; @@ -32,7 +32,7 @@ export function PanelShell({ currentPath, title, description, - caregiverToken, + caregiverLabel, children, actions, eyebrow = "家人连线", @@ -81,8 +81,16 @@ export function PanelShell({
- 账号:{caregiverToken.slice(0, 8).toUpperCase()} + 账号:{caregiverLabel} +
+ +
diff --git a/components/scan-client.tsx b/components/scan-client.tsx index 2d3762c..ea01830 100644 --- a/components/scan-client.tsx +++ b/components/scan-client.tsx @@ -45,38 +45,13 @@ export function ScanClient() { scannerRef.current.stop(); setErrorText(null); - setStatusText("识别到了设备,正在连接……"); + setStatusText("识别到了设备,正在打开绑定页面……"); - try { - const response = await fetch("/api/family/bind", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ deviceUuid: scannedValue }), - }); - - const payload = (await response.json().catch(() => null)) as - | { error?: string; deviceUuid?: string } - | null; - const deviceUuid = payload?.deviceUuid; - - if (!response.ok || !deviceUuid) { - throw new Error(payload?.error || "连接失败,请重新扫描。"); - } - - startTransition(() => { - router.replace( - `/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`, - ); - }); - } catch (error) { - setErrorText( - error instanceof Error ? error.message : "连接失败,请重新扫描。", + startTransition(() => { + router.replace( + `/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`, ); - setStatusText("请再次将二维码对准镜头。"); - await scannerRef.current.start().catch(() => null); - } + }); }); useEffect(() => { diff --git a/lib/monitor-data.ts b/lib/monitor-data.ts index 8103674..a375366 100644 --- a/lib/monitor-data.ts +++ b/lib/monitor-data.ts @@ -121,6 +121,8 @@ export async function bindDeviceToCaregiver( const device = await ensureDeviceRegistration({ deviceUuid }); + await migrateLegacyCaregiverDataForDevice(caregiverId, device.id); + await prisma.deviceBinding.upsert({ where: { caregiverId_elderDeviceId: { @@ -138,6 +140,102 @@ export async function bindDeviceToCaregiver( return device; } +async function migrateLegacyCaregiverDataForDevice( + caregiverId: string, + elderDeviceId: string, +) { + const legacyCaregivers = await prisma.caregiverAccount.findMany({ + where: { + id: { not: caregiverId }, + username: null, + bindings: { + some: { + elderDeviceId, + }, + }, + }, + select: { + id: true, + }, + take: 2, + }); + + if (legacyCaregivers.length !== 1) { + return; + } + + const legacyCaregiverId = legacyCaregivers[0]?.id; + + if (!legacyCaregiverId) { + return; + } + + await prisma.$transaction(async (tx) => { + const legacyBindings = await tx.deviceBinding.findMany({ + where: { + caregiverId: legacyCaregiverId, + }, + select: { + elderDeviceId: true, + }, + }); + + await Promise.all( + legacyBindings.map((binding) => + tx.deviceBinding.upsert({ + where: { + caregiverId_elderDeviceId: { + caregiverId, + elderDeviceId: binding.elderDeviceId, + }, + }, + update: {}, + create: { + caregiverId, + elderDeviceId: binding.elderDeviceId, + }, + }), + ), + ); + + await tx.familyMessage.updateMany({ + where: { + caregiverId: legacyCaregiverId, + }, + data: { + caregiverId, + }, + }); + + await tx.pushSubscription.updateMany({ + where: { + caregiverId: legacyCaregiverId, + }, + data: { + caregiverId, + }, + }); + + await tx.deviceBinding.deleteMany({ + where: { + caregiverId: legacyCaregiverId, + }, + }); + + await tx.caregiverSession.deleteMany({ + where: { + caregiverId: legacyCaregiverId, + }, + }); + + await tx.caregiverAccount.delete({ + where: { + id: legacyCaregiverId, + }, + }); + }); +} + function parseImportance(rawImportance?: string | null) { const normalizedImportance = rawImportance?.trim().toUpperCase(); diff --git a/lib/page-auth.ts b/lib/page-auth.ts index 80defe0..449ea1b 100644 --- a/lib/page-auth.ts +++ b/lib/page-auth.ts @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { - buildCaregiverSessionBootstrapPath, + buildCaregiverLoginPath, getCaregiverSession, } from "@/lib/session"; @@ -9,7 +9,7 @@ export async function requireCaregiverSession(redirectTo: string) { const caregiver = await getCaregiverSession(); if (!caregiver) { - redirect(buildCaregiverSessionBootstrapPath(redirectTo)); + redirect(buildCaregiverLoginPath(redirectTo)); } return caregiver; diff --git a/lib/session.ts b/lib/session.ts index fbd8b96..ffedb37 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -1,3 +1,4 @@ +import { compare, hash } from "bcryptjs"; import { cookies } from "next/headers"; import { randomUUID } from "node:crypto"; @@ -6,6 +7,8 @@ import { prisma } from "@/lib/prisma"; export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session"; const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365; +const CAREGIVER_USERNAME_PATTERN = /^[a-z0-9][a-z0-9_-]{2,23}$/; +const CAREGIVER_PASSWORD_MIN_LENGTH = 8; function createSessionToken() { return randomUUID().replaceAll("-", ""); @@ -29,44 +32,170 @@ function normalizeRedirectPath(redirectTo?: string) { return redirectTo; } -async function upsertCaregiverByToken(sessionToken: string) { - return prisma.caregiverAccount.upsert({ - where: { sessionToken }, - update: {}, - create: { sessionToken }, - }); +function normalizeCaregiverUsername(rawValue: string) { + return rawValue.trim().toLowerCase(); } -export function buildCaregiverSessionBootstrapPath(redirectTo: string) { +function parseCaregiverUsername(rawValue: string) { + const username = normalizeCaregiverUsername(rawValue); + + if (!CAREGIVER_USERNAME_PATTERN.test(username)) { + throw new Error("用户名请使用 3 到 24 位字母、数字、下划线或横线。"); + } + + return username; +} + +function parseCaregiverPassword(rawValue: string) { + if (rawValue.length < CAREGIVER_PASSWORD_MIN_LENGTH) { + throw new Error(`密码至少需要 ${CAREGIVER_PASSWORD_MIN_LENGTH} 位。`); + } + + if (rawValue.length > 72) { + throw new Error("密码请控制在 72 位以内。"); + } + + return rawValue; +} + +async function getCurrentSessionToken() { + return (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value || null; +} + +async function createCaregiverSession(input: { + caregiverId: string; + userAgent?: string | null; +}) { + const currentSessionToken = await getCurrentSessionToken(); + + if (currentSessionToken) { + await prisma.caregiverSession.deleteMany({ + where: { + sessionToken: currentSessionToken, + }, + }); + } + + const sessionToken = createSessionToken(); + + await prisma.caregiverSession.create({ + data: { + caregiverId: input.caregiverId, + sessionToken, + userAgent: input.userAgent?.trim() || null, + lastSeenAt: new Date(), + }, + }); + + (await cookies()).set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions()); +} + +export function buildCaregiverLoginPath(redirectTo: string) { const normalizedRedirect = normalizeRedirectPath(redirectTo); - return `/api/family/session?redirectTo=${encodeURIComponent(normalizedRedirect)}`; + return `/login?redirectTo=${encodeURIComponent(normalizedRedirect)}`; +} + +export function getCaregiverDisplayName(input: { + username?: string | null; + nickname?: string | null; +}) { + return input.nickname?.trim() || input.username?.trim() || "临时账号"; +} + +export function sanitizeCaregiverRedirectPath(redirectTo?: string | null) { + return normalizeRedirectPath(redirectTo || undefined); } export async function getCaregiverSession() { - const sessionToken = (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value; + const sessionToken = await getCurrentSessionToken(); if (!sessionToken) { return null; } - return upsertCaregiverByToken(sessionToken); -} + const session = await prisma.caregiverSession.findUnique({ + where: { sessionToken }, + include: { + caregiver: true, + }, + }); -export async function getOrCreateCaregiverSession() { - const cookieStore = await cookies(); - let sessionToken = cookieStore.get(CAREGIVER_SESSION_COOKIE)?.value; - - if (!sessionToken) { - sessionToken = createSessionToken(); - cookieStore.set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions()); + if (!session) { + return null; } - return upsertCaregiverByToken(sessionToken); + return session.caregiver; } -export function createCaregiverSessionCookieValue() { - return { - sessionToken: createSessionToken(), - options: getCookieOptions(), - }; +export async function registerCaregiverAccount(input: { + username: string; + password: string; + userAgent?: string | null; +}) { + const username = parseCaregiverUsername(input.username); + const password = parseCaregiverPassword(input.password); + const existingCaregiver = await prisma.caregiverAccount.findUnique({ + where: { username }, + }); + + if (existingCaregiver) { + throw new Error("这个用户名已经被注册了,请直接登录。"); + } + + const caregiver = await prisma.caregiverAccount.create({ + data: { + username, + passwordHash: await hash(password, 10), + }, + }); + + await createCaregiverSession({ + caregiverId: caregiver.id, + userAgent: input.userAgent, + }); + + return caregiver; +} + +export async function loginCaregiverAccount(input: { + username: string; + password: string; + userAgent?: string | null; +}) { + const username = parseCaregiverUsername(input.username); + const password = parseCaregiverPassword(input.password); + const caregiver = await prisma.caregiverAccount.findUnique({ + where: { username }, + }); + + if (!caregiver?.passwordHash) { + throw new Error("用户名或密码不正确。"); + } + + const passwordMatched = await compare(password, caregiver.passwordHash); + + if (!passwordMatched) { + throw new Error("用户名或密码不正确。"); + } + + await createCaregiverSession({ + caregiverId: caregiver.id, + userAgent: input.userAgent, + }); + + return caregiver; +} + +export async function clearCurrentCaregiverSession() { + const sessionToken = await getCurrentSessionToken(); + + if (sessionToken) { + await prisma.caregiverSession.deleteMany({ + where: { + sessionToken, + }, + }); + } + + (await cookies()).delete(CAREGIVER_SESSION_COOKIE); } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6208098..9c4c0cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", + "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "lucide-react": "^1.8.0", "next": "16.2.4", @@ -575,6 +576,111 @@ "fast-glob": "3.3.1" } }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-win32-x64-msvc": { "version": "16.2.4", "cpu": [ @@ -1755,6 +1861,14 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-result": { "version": "2.8.2", "devOptional": true, @@ -6189,111 +6303,6 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", - "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", - "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", - "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", - "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", - "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", - "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", - "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 4c50ae5..f92340f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", + "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "lucide-react": "^1.8.0", "next": "16.2.4", diff --git a/prisma/migrations/20260417045149_account_auth_multi_device/migration.sql b/prisma/migrations/20260417045149_account_auth_multi_device/migration.sql new file mode 100644 index 0000000..4b14768 --- /dev/null +++ b/prisma/migrations/20260417045149_account_auth_multi_device/migration.sql @@ -0,0 +1,59 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `CaregiverAccount` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "CaregiverAccount" ADD COLUMN "passwordHash" TEXT, +ADD COLUMN "username" TEXT; + +-- CreateTable +CREATE TABLE "CaregiverSession" ( + "id" TEXT NOT NULL, + "caregiverId" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userAgent" TEXT, + "lastSeenAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CaregiverSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CaregiverSession_sessionToken_key" ON "CaregiverSession"("sessionToken"); + +-- CreateIndex +CREATE INDEX "CaregiverSession_caregiverId_updatedAt_idx" ON "CaregiverSession"("caregiverId", "updatedAt" DESC); + +-- CreateIndex +CREATE UNIQUE INDEX "CaregiverAccount_username_key" ON "CaregiverAccount"("username"); + +-- MigrateData +INSERT INTO "CaregiverSession" ( + "id", + "caregiverId", + "sessionToken", + "lastSeenAt", + "createdAt", + "updatedAt" +) +SELECT + CONCAT('legacy_', md5("id" || ':' || "sessionToken")), + "id", + "sessionToken", + NOW(), + NOW(), + NOW() +FROM "CaregiverAccount" +WHERE "sessionToken" IS NOT NULL; + +-- DropIndex +DROP INDEX "CaregiverAccount_sessionToken_key"; + +-- AlterTable +ALTER TABLE "CaregiverAccount" DROP COLUMN "sessionToken"; + +-- AddForeignKey +ALTER TABLE "CaregiverSession" ADD CONSTRAINT "CaregiverSession_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 040b3d7..b6bb398 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,16 +46,31 @@ enum ToolCallStatus { } model CaregiverAccount { - id String @id @default(cuid()) - sessionToken String @unique - nickname String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - bindings DeviceBinding[] - messages FamilyMessage[] + id String @id @default(cuid()) + username String? @unique + passwordHash String? + nickname String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions CaregiverSession[] + bindings DeviceBinding[] + messages FamilyMessage[] pushSubscriptions PushSubscription[] } +model CaregiverSession { + id String @id @default(cuid()) + caregiverId String + sessionToken String @unique + userAgent String? + lastSeenAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade) + + @@index([caregiverId, updatedAt(sort: Desc)]) +} + model ElderDevice { id String @id @default(cuid()) deviceUuid String @unique