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}
+
+
+
+ )}
+
+
+
| Name | Email | Roles |
@@ -90,6 +135,7 @@ function UsersTab() {
+
);
}
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.
+
+ )}
+
);
}