Add admin-emailed one-time intake links

- intake_tokens table (server-only via service role)
- Server functions: createIntakeToken (admin), getIntakeToken (public validate),
  submitIntake (public write + mark used, 14-day one-time tokens)
- Public /intake/$token full intake form (no login) with valid/used/expired states
- Student profile (admin): generate link, copy, and email-to-parent (mailto)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:00:39 -04:00
co-authored by Claude Opus 4.8
parent eb94c7ec34
commit 90e3c2c188
6 changed files with 393 additions and 1 deletions
+39 -1
View File
@@ -10,10 +10,11 @@ import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy } from "lucide-react";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy, Link2, Mail } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { createIntakeToken } from "@/lib/intake.functions";
import { genTempPassword } from "@/lib/temp-password";
import { StudentGradeReport } from "@/components/gradebook";
@@ -323,6 +324,43 @@ function FamilyTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit
<PickupList studentId={studentId} editing={editing} kind="pickup" title="Authorized pick-up" subtitle="Only these individuals may pick up the student (valid ID required)." />
<PickupList studentId={studentId} editing={editing} kind="emergency" title="Emergency contacts" subtitle="Contacted if guardians are unreachable." />
{isAdmin && <ParentAccessSection studentId={studentId} />}
{isAdmin && <IntakeLinkSection studentId={studentId} />}
</div>
);
}
function IntakeLinkSection({ studentId }: { studentId: string }) {
const { data: student } = useQuery({
queryKey: ["student-name", studentId],
queryFn: async () => (await supabase.from("students").select("first_name, last_name").eq("id", studentId).maybeSingle()).data,
});
const name = student ? `${student.first_name} ${student.last_name}` : "this student";
const [link, setLink] = useState<string | null>(null);
const gen = useMutation({
mutationFn: async () => {
const { token } = await createIntakeToken({ data: { studentId } });
return `${window.location.origin}/intake/${token}`;
},
onSuccess: (url) => { setLink(url); toast.success("Intake link created"); },
onError: (e: Error) => toast.error(e.message),
});
const mailto = link
? `mailto:?subject=${encodeURIComponent(`Student intake for ${name} — Bayside Academy`)}&body=${encodeURIComponent(`Please complete the student intake form for ${name} using this one-time link (valid 14 days):\n\n${link}\n\nThank you,\nBayside Academy`)}`
: "#";
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><Link2 className="h-4 w-4" /> Intake form link</h3><p className="text-xs text-muted-foreground">Generate a one-time link a parent can use to fill out this student's intake — no login required, valid 14 days.</p></div>
<Button size="sm" variant="outline" onClick={() => gen.mutate()} disabled={gen.isPending}>{gen.isPending ? "Creating…" : link ? "Generate a new link" : "Generate intake link"}</Button>
{link && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm space-y-2">
<div className="font-mono text-xs break-all">{link}</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(link); toast.success("Copied"); }}><Copy className="h-4 w-4 mr-1" /> Copy link</Button>
<a href={mailto}><Button size="sm"><Mail className="h-4 w-4 mr-1" /> Email to parent</Button></a>
</div>
<p className="text-xs text-muted-foreground">Note: this link lets whoever opens it fill out {name}'s intake once. Share it only with the parent.</p>
</div>
)}
</div>
);
}