新增家长注册页 /register:凭家庭邀请码注册加入并自动登录
This commit is contained in:
@@ -65,6 +65,15 @@ export default function LoginPage() {
|
|||||||
去输入激活码
|
去输入激活码
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
家里另一位家长想加入?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-semibold text-indigo-600 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
用邀请码加入家庭
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { familyInvites, users } from "@/db/schema";
|
||||||
|
import { createSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
function hashCode(code: string) {
|
||||||
|
return crypto.createHash("sha256").update(code).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
displayName: z.string().trim().min(1, "请输入称呼").max(20, "称呼最多 20 字"),
|
||||||
|
phone: z.string().regex(/^1\d{10}$/, "请输入 11 位手机号"),
|
||||||
|
password: z.string().min(6, "密码至少 6 位"),
|
||||||
|
code: z.string().regex(/^\d{6}$/, "邀请码是 6 位数字"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RegisterState = { error?: string } | null;
|
||||||
|
|
||||||
|
// xuchao: 家长凭邀请码注册:校验码→查重手机号→事务(码标记已用+建家长)→自动登录跳家长中心
|
||||||
|
export async function registerWithInvite(
|
||||||
|
_prev: RegisterState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<RegisterState> {
|
||||||
|
const parsed = registerSchema.safeParse({
|
||||||
|
displayName: formData.get("displayName"),
|
||||||
|
phone: formData.get("phone"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
code: formData.get("code"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.issues[0].message };
|
||||||
|
|
||||||
|
const { displayName, phone, password, code } = parsed.data;
|
||||||
|
|
||||||
|
const [invite] = await db
|
||||||
|
.select({ id: familyInvites.id, familyId: familyInvites.familyId })
|
||||||
|
.from(familyInvites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(familyInvites.codeHash, hashCode(code)),
|
||||||
|
isNull(familyInvites.usedAt),
|
||||||
|
gt(familyInvites.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!invite) return { error: "邀请码无效或已过期,请让家长重新生成" };
|
||||||
|
|
||||||
|
const [dup] = await db.select({ id: users.id }).from(users).where(eq(users.phone, phone)).limit(1);
|
||||||
|
if (dup) return { error: "该手机号已注册" };
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const newUserId = await db.transaction(async (tx) => {
|
||||||
|
await tx.update(familyInvites).set({ usedAt: new Date() }).where(eq(familyInvites.id, invite.id));
|
||||||
|
const [created] = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({ familyId: invite.familyId, role: "parent", displayName, phone, passwordHash })
|
||||||
|
.$returningId();
|
||||||
|
return created.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await createSession(newUserId, "家长设备", 30);
|
||||||
|
redirect("/parent");
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||||
|
import { registerWithInvite } from "./actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [state, formAction, pending] = useActionState(registerWithInvite, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-dvh items-center justify-center p-4">
|
||||||
|
<BackgroundBlobs />
|
||||||
|
<Card className="w-full max-w-sm border-slate-100 shadow-soft transition-shadow duration-200 hover:shadow-lift">
|
||||||
|
<CardHeader className="items-center gap-3 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-btn">
|
||||||
|
<UserPlus className="size-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight">加入家庭</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">需要家里已有成员的邀请码</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="displayName">称呼</Label>
|
||||||
|
<Input id="displayName" name="displayName" placeholder="如:妈妈" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">手机号</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="11 位手机号"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code">邀请码</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="6 位数字"
|
||||||
|
className="font-mono tracking-widest"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{state?.error && <p className="text-sm font-medium text-destructive">{state.error}</p>}
|
||||||
|
<Button type="submit" className="w-full" size="lg" disabled={pending}>
|
||||||
|
{pending ? "注册中…" : "注册并加入家庭"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
已有账号?{" "}
|
||||||
|
<Link href="/login" className="font-semibold text-indigo-600 underline-offset-4 hover:underline">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user