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
+7
View File
@@ -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("");
}
+47
View File
@@ -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 };
});