添加账号系统
This commit is contained in:
parent
032e207888
commit
d3b03cecf2
@ -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 ? (
|
||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
||||
|
||||
44
app/api/account/login/route.ts
Normal file
44
app/api/account/login/route.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
11
app/api/account/logout/route.ts
Normal file
11
app/api/account/logout/route.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
45
app/api/account/register/route.ts
Normal file
45
app/api/account/register/route.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<ReturnType<typeof bindDeviceToCaregiver>> | 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 (
|
||||
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||
暂时没连上
|
||||
</p>
|
||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||
这次还没能完成设备连接
|
||||
</h1>
|
||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||
{bindError || "请重新扫码,或请长辈重新打开设备连接页面后再试一次。"}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/scan"
|
||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||
>
|
||||
重新扫码
|
||||
</Link>
|
||||
<Link
|
||||
href="/devices"
|
||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||
>
|
||||
返回设备页
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||
|
||||
@ -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={<PwaControls />}
|
||||
>
|
||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
|
||||
79
app/login/page.tsx
Normal file
79
app/login/page.tsx
Normal file
@ -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<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<main className="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="grid w-full gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<div className="relative overflow-hidden rounded-[40px] border border-white/75 bg-[linear-gradient(160deg,rgba(53,84,141,0.95),rgba(28,45,80,0.94))] p-7 text-white shadow-[0_36px_90px_rgba(53,84,141,0.24)] sm:p-8">
|
||||
<div className="absolute -right-10 top-[-40px] h-44 w-44 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute bottom-[-48px] left-[-12px] h-40 w-40 rounded-full border border-white/16" />
|
||||
|
||||
<p className="relative text-sm font-semibold tracking-[0.22em] text-white/78 uppercase">
|
||||
家人连线
|
||||
</p>
|
||||
<h1 className="relative mt-5 font-[family-name:var(--font-display)] text-4xl leading-[1.18] sm:text-5xl">
|
||||
用同一个账号,
|
||||
<br />
|
||||
在每一台家属设备上继续守护
|
||||
</h1>
|
||||
<p className="relative mt-5 max-w-2xl text-base leading-8 text-white/78 sm:text-lg">
|
||||
现在开始,设备连接、留言记录和消息提醒都按账号归属。您在手机、平板或电脑上登录同一个账号,长辈发来的新留言都会一起送达。
|
||||
</p>
|
||||
|
||||
<div className="relative mt-8 grid gap-3">
|
||||
{[
|
||||
"绑定过的长辈设备会跟随账号保留,不再分散到不同浏览器会话。",
|
||||
"一台设备开了提醒,另一台设备也登录同一个账号时,同样可以独立接收推送。",
|
||||
"登录后会自动回到刚才的页面,扫码和绑定流程不用重新开始。",
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-[22px] border border-white/12 bg-white/8 px-5 py-4 text-sm leading-7 text-white/84"
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] sm:p-7">
|
||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||
账号登录
|
||||
</p>
|
||||
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||
先登录,再继续刚才的操作
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
||||
如果您刚刚在扫码或添加设备,登录完成后会自动回到原来的页面,不需要重新输入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AccountAuthForm redirectTo={redirectTo} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
>
|
||||
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||
|
||||
@ -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={<PwaControls />}
|
||||
>
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
|
||||
@ -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={<PwaControls />}
|
||||
>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
|
||||
189
components/account-auth-form.tsx
Normal file
189
components/account-auth-form.tsx
Normal file
@ -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<AuthMode>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="rounded-[32px] border border-white/75 bg-white/84 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur sm:p-7">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[
|
||||
{ id: "login", label: "已有账号,直接登录" },
|
||||
{ id: "register", label: "第一次使用,创建账号" },
|
||||
].map((item) => {
|
||||
const active = mode === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(item.id as AuthMode);
|
||||
setNotice(null);
|
||||
}}
|
||||
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
|
||||
active
|
||||
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
||||
: "border border-[var(--line)] bg-white text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-username">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="caregiver-username"
|
||||
value={username}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
|
||||
建议使用 3 到 24 位字母、数字、下划线或横线,方便全家统一登录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="caregiver-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
|
||||
密码至少 8 位。同一账号可以在多台家属设备登录,消息提醒会同步到所有已登录设备。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "register" ? (
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password-confirm">
|
||||
再输入一次密码
|
||||
</label>
|
||||
<input
|
||||
id="caregiver-password-confirm"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="inline-flex h-12 w-full items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting
|
||||
? mode === "login"
|
||||
? "正在登录"
|
||||
: "正在创建账号"
|
||||
: mode === "login"
|
||||
? "登录并继续"
|
||||
: "创建账号并继续"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-4 rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--muted)]">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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)} 次`, "次数"]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
|
||||
@ -19,37 +19,11 @@ export function BindDeviceForm() {
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setNotice("正在建立连接,请稍候……");
|
||||
setNotice("正在前往绑定页面,请稍候……");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/family/bind", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceUuid: rawCode }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || "连接失败,请稍后再试。");
|
||||
}
|
||||
|
||||
setNotice("连接成功!现在可以查看长辈的留言和动态了。\n");
|
||||
setRawCode("");
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
error instanceof Error ? error.message : "连接失败,请稍后再试。",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -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({
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
||||
账号:{caregiverToken.slice(0, 8).toUpperCase()}
|
||||
账号:{caregiverLabel}
|
||||
</span>
|
||||
<form action="/api/account/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-10 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -45,38 +45,13 @@ export function ScanClient() {
|
||||
|
||||
scannerRef.current.stop();
|
||||
setErrorText(null);
|
||||
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 || "连接失败,请重新扫描。");
|
||||
}
|
||||
setStatusText("识别到了设备,正在打开绑定页面……");
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(
|
||||
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`,
|
||||
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorText(
|
||||
error instanceof Error ? error.message : "连接失败,请重新扫描。",
|
||||
);
|
||||
setStatusText("请再次将二维码对准镜头。");
|
||||
await scannerRef.current.start().catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
177
lib/session.ts
177
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);
|
||||
}
|
||||
219
package-lock.json
generated
219
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -47,15 +47,30 @@ enum ToolCallStatus {
|
||||
|
||||
model CaregiverAccount {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user