From 73a4e70f0b4ad7de43b69a3ee0edbab8ec102edd Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 19 Jul 2026 16:30:56 -0400 Subject: [PATCH] Parent hand-off: admin creates parent/teacher accounts + portal edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 3 + docker-compose.yml | 7 ++- src/lib/temp-password.ts | 7 +++ src/lib/user-admin.functions.ts | 47 ++++++++++++++ src/routes/_authenticated/admin.tsx | 50 ++++++++++++++- src/routes/_authenticated/students.$id.tsx | 71 +++++++++++++++++++--- 6 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 src/lib/temp-password.ts create mode 100644 src/lib/user-admin.functions.ts diff --git a/.gitignore b/.gitignore index 698a308..014f20c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ dist-ssr # Supabase CLI local state supabase/.temp supabase/.branches + +# Runtime secrets (service role key etc.) — never commit +.env.secret .output .vinxi .tanstack/** diff --git a/docker-compose.yml b/docker-compose.yml index 866f0ca..4feeab6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,14 +11,15 @@ services: image: info-share-spot:latest container_name: info-share-spot restart: unless-stopped + # Runtime secrets (SUPABASE_SERVICE_ROLE_KEY) live here — gitignored, not in the repo. + env_file: + - path: .env.secret + required: false environment: # SSR runtime vars (public-safe). Compose substitutes these from the .env # file next to this compose file. SUPABASE_URL: ${SUPABASE_URL} SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY} - # Only needed if you ever use the server-side admin (service-role) client. - # Set it in Dockge's env editor, never commit it: - # SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} HOST: 0.0.0.0 PORT: 3000 labels: diff --git a/src/lib/temp-password.ts b/src/lib/temp-password.ts new file mode 100644 index 0000000..6c0376d --- /dev/null +++ b/src/lib/temp-password.ts @@ -0,0 +1,7 @@ +// Generate a readable temporary password (no ambiguous chars) for new accounts. +export function genTempPassword(len = 12): string { + const chars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; + const arr = new Uint32Array(len); + crypto.getRandomValues(arr); + return Array.from(arr, (n) => chars[n % chars.length]).join(""); +} diff --git a/src/lib/user-admin.functions.ts b/src/lib/user-admin.functions.ts new file mode 100644 index 0000000..f991492 --- /dev/null +++ b/src/lib/user-admin.functions.ts @@ -0,0 +1,47 @@ +import { createServerFn } from "@tanstack/react-start"; +import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; + +type Role = "admin" | "teacher" | "parent"; + +// Admin-only: create a user account (service-role, server-side), set its role, +// and optionally link it to a student (for parent hand-off). +export const createUserFn = createServerFn({ method: "POST" }) + .middleware([requireSupabaseAuth]) + .validator((d: { fullName: string; email: string; password: string; role: Role; linkStudentId?: string }) => d) + .handler(async ({ data, context }) => { + const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); + const callerId = (context as { userId: string }).userId; + + const { data: adminRow } = await supabaseAdmin + .from("user_roles").select("role").eq("user_id", callerId).eq("role", "admin").maybeSingle(); + if (!adminRow) throw new Error("Only admins can add users."); + + const email = data.email.trim().toLowerCase(); + if (!email || !data.fullName.trim()) throw new Error("Name and email are required."); + + const { data: created, error } = await supabaseAdmin.auth.admin.createUser({ + email, + password: data.password, + email_confirm: true, + user_metadata: { full_name: data.fullName.trim() }, + }); + if (error) throw new Error(error.message); + const uid = created.user?.id; + if (!uid) throw new Error("User creation failed."); + + // The on_auth_user_created trigger assigns 'parent' by default; adjust to the chosen role. + if (data.role !== "parent") { + await supabaseAdmin.from("user_roles").delete().eq("user_id", uid).eq("role", "parent"); + } + const { error: rErr } = await supabaseAdmin + .from("user_roles").upsert({ user_id: uid, role: data.role }, { onConflict: "user_id,role" }); + if (rErr) throw new Error(rErr.message); + + if (data.linkStudentId && data.role === "parent") { + const { error: lErr } = await supabaseAdmin + .from("parent_students").upsert({ parent_id: uid, student_id: data.linkStudentId }, { onConflict: "parent_id,student_id" }); + if (lErr) throw new Error(lErr.message); + } + + return { id: uid, email }; + }); diff --git a/src/routes/_authenticated/admin.tsx b/src/routes/_authenticated/admin.tsx index 41f7b7c..f46223f 100644 --- a/src/routes/_authenticated/admin.tsx +++ b/src/routes/_authenticated/admin.tsx @@ -7,9 +7,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, Trash2, Lock } from "lucide-react"; +import { Plus, Trash2, Lock, 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"; type Role = "admin" | "teacher" | "parent"; @@ -68,8 +70,51 @@ function UsersTab() { onError: (e: Error) => toast.error(e.message), }); + const [nu, setNu] = useState({ fullName: "", email: "", role: "teacher" as Role }); + const [created, setCreated] = useState<{ email: string; password: string } | null>(null); + const addUser = useMutation({ + mutationFn: async () => { + const password = genTempPassword(); + const res = await createUserFn({ data: { fullName: nu.fullName, email: nu.email, password, role: nu.role } }); + return { email: res.email, password }; + }, + onSuccess: (r) => { + setCreated(r); + setNu({ fullName: "", email: "", role: "teacher" }); + qc.invalidateQueries({ queryKey: ["admin-users"] }); + qc.invalidateQueries({ queryKey: ["teachers"] }); + toast.success(`Created ${r.email}`); + }, + onError: (e: Error) => toast.error(e.message), + }); + return ( -
+
+
+
Add a user
+
+ setNu({ ...nu, fullName: e.target.value })} /> + setNu({ ...nu, email: e.target.value })} /> + + +
+

Creates a login with a temporary password (shown once). They can change it after signing in. No email is sent.

+ {created && ( +
+
Account created — share these with {created.email}:
+
+ Email: {created.email} + Password: {created.password} + +
+
+ )} +
+ +
@@ -90,6 +135,7 @@ function UsersTab() {
NameEmailRoles
+
); } diff --git a/src/routes/_authenticated/students.$id.tsx b/src/routes/_authenticated/students.$id.tsx index 415f3c9..1374bb4 100644 --- a/src/routes/_authenticated/students.$id.tsx +++ b/src/routes/_authenticated/students.$id.tsx @@ -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() { Tuition {isAdmin && Contracts} - - - + + + {isAdmin && } @@ -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 - + {isAdmin && }
@@ -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")}
upd("photo_release", v)} />
- {A("notes", "Internal notes (staff only)")} + {isAdmin && A("notes", "Internal notes (staff only)")}
{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}
@@ -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
+ {isAdmin && } + + ); +} + +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 ( +
+

Parent portal access

Create a login so a parent can sign in and fill out this profile.

+
+ {(parents ?? []).map((p) =>
{p.full_name || "—"}
{p.email}
)} + {parents?.length === 0 &&
No parent accounts linked yet.
} +
+
+
+ setNp({ ...np, fullName: e.target.value })} /> + setNp({ ...np, email: e.target.value })} /> + +
+ {created && ( +
+
Login created — give these to the parent:
+
+ {created.email} + {created.password} + +
+

They sign in at the portal, open this student, and hit Edit to complete the profile.

+
+ )} +
); }