Parent hand-off: admin creates parent/teacher accounts + portal edit

- Admin server function (service-role) to create accounts, set role, and
  optionally link a parent to a student; wired via env_file (.env.secret)
- Admin > Users: "Add a user" form (teacher/parent/admin) with one-time temp password
- Student profile > Family: "Parent portal access" — create + link a parent login
- Parents can now edit their own child's profile (RLS-scoped); internal notes stay admin-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:30:56 -04:00
co-authored by Claude Opus 4.8
parent 894b0722e0
commit 73a4e70f0b
6 changed files with 172 additions and 13 deletions
+63 -8
View File
@@ -10,9 +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 } from "lucide-react";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { genTempPassword } from "@/lib/temp-password";
export const Route = createFileRoute("/_authenticated/students/$id")({
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
@@ -59,6 +61,7 @@ function StudentDetail() {
const { id } = Route.useParams();
const { roles } = useAuth();
const isAdmin = roles.includes("admin");
const canEdit = isAdmin || roles.includes("parent"); // parents can fill out their own child's profile (RLS-scoped)
const { data: student } = useQuery({
queryKey: ["student", id],
@@ -89,9 +92,9 @@ function StudentDetail() {
<TabsTrigger value="ledger">Tuition</TabsTrigger>
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
</TabsList>
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="family"><FamilyTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="family"><FamilyTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={canEdit} /></TabsContent>
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
@@ -142,7 +145,7 @@ function PhotoAvatar({ studentId, photoPath, canEdit }: { studentId: string; pho
}
// ── Profile ──────────────────────────────────────────────────────────────────
function ProfileTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit: boolean; isAdmin: boolean }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const { data: s } = useQuery({
@@ -211,7 +214,7 @@ function ProfileTab({ studentId, canEdit }: { studentId: string; canEdit: boolea
<ViewRow label="Interests / hobbies" value={val("interests")} />
<ViewRow label="Other notes" value={val("other_info")} />
<ViewRow label="Photo release" value={c.photo_release ? "Granted" : "Not granted"} />
<ViewRow label="Internal notes" value={val("notes")} />
{isAdmin && <ViewRow label="Internal notes" value={val("notes")} />}
</Section>
<Section title="Agreement">
<ViewRow label="Signed by" value={val("agreement_signed_by")} />
@@ -268,7 +271,7 @@ function ProfileTab({ studentId, canEdit }: { studentId: string; canEdit: boolea
{A("interests", "Interests / hobbies / activities")}
{A("other_info", "Anything else we should know")}
<div className="flex items-center justify-between max-w-sm"><Label className="text-xs">Photo release granted</Label><Switch checked={!!c.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
{A("notes", "Internal notes (staff only)")}
{isAdmin && A("notes", "Internal notes (staff only)")}
</Section>
<Section title="Agreement">
<div className="grid grid-cols-2 gap-3">{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}</div>
@@ -284,7 +287,7 @@ function ProfileTab({ studentId, canEdit }: { studentId: string; canEdit: boolea
// ── Family & pickup ──────────────────────────────────────────────────────────
type GuardianRow = { id: string; is_primary: boolean; full_name: string; relationship: string | null; phone: string | null; address: string | null; city: string | null; state: string | null; zip: string | null; email: string | null; employer: string | null };
function FamilyTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
function FamilyTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit: boolean; isAdmin: boolean }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const { data: guardians } = useQuery({
@@ -312,6 +315,58 @@ function FamilyTab({ studentId, canEdit }: { studentId: string; canEdit: boolean
</div>
<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} />}
</div>
);
}
function ParentAccessSection({ studentId }: { studentId: string }) {
const qc = useQueryClient();
const { data: parents } = useQuery({
queryKey: ["student-parents", studentId],
queryFn: async () => {
const { data } = await supabase.from("parent_students").select("parent_id").eq("student_id", studentId);
const ids = (data ?? []).map((d) => d.parent_id);
if (!ids.length) return [];
return (await supabase.from("profiles").select("id, full_name, email").in("id", ids)).data ?? [];
},
});
const [np, setNp] = useState({ fullName: "", email: "" });
const [created, setCreated] = useState<{ email: string; password: string } | null>(null);
const invite = useMutation({
mutationFn: async () => {
const password = genTempPassword();
const res = await createUserFn({ data: { fullName: np.fullName, email: np.email, password, role: "parent", linkStudentId: studentId } });
return { email: res.email, password };
},
onSuccess: (r) => { setCreated(r); setNp({ fullName: "", email: "" }); qc.invalidateQueries({ queryKey: ["student-parents", studentId] }); toast.success("Parent account created & linked"); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><UserPlus className="h-4 w-4" /> Parent portal access</h3><p className="text-xs text-muted-foreground">Create a login so a parent can sign in and fill out this profile.</p></div>
<div className="bg-card border rounded-lg divide-y">
{(parents ?? []).map((p) => <div key={p.id} className="p-3"><div className="font-medium text-sm">{p.full_name || "—"}</div><div className="text-xs text-muted-foreground">{p.email}</div></div>)}
{parents?.length === 0 && <div className="p-3 text-sm text-muted-foreground">No parent accounts linked yet.</div>}
</div>
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<Input placeholder="Parent full name" value={np.fullName} onChange={(e) => setNp({ ...np, fullName: e.target.value })} />
<Input type="email" placeholder="Parent email" value={np.email} onChange={(e) => setNp({ ...np, email: e.target.value })} />
<Button onClick={() => invite.mutate()} disabled={!np.fullName || !np.email || invite.isPending}>{invite.isPending ? "Creating…" : "Create & link parent"}</Button>
</div>
{created && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
<div className="font-medium">Login created — give these to the parent:</div>
<div className="mt-1 flex flex-wrap items-center gap-2 font-mono text-xs">
<span className="rounded bg-background border px-2 py-1">{created.email}</span>
<span className="rounded bg-background border px-2 py-1">{created.password}</span>
<Button size="icon" variant="ghost" onClick={() => { navigator.clipboard.writeText(`Sign in at ${window.location.origin}/auth\nEmail: ${created.email}\nPassword: ${created.password}`); toast.success("Copied"); }}><Copy className="h-4 w-4" /></Button>
</div>
<p className="text-xs text-muted-foreground mt-1">They sign in at the portal, open this student, and hit Edit to complete the profile.</p>
</div>
)}
</div>
</div>
);
}