diff --git a/src/routes/_authenticated/admin.tsx b/src/routes/_authenticated/admin.tsx
new file mode 100644
index 0000000..f46223f
--- /dev/null
+++ b/src/routes/_authenticated/admin.tsx
@@ -0,0 +1,284 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import { Button } from "@/components/ui/button";
+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, 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";
+
+export const Route = createFileRoute("/_authenticated/admin")({
+ head: () => ({ meta: [{ title: "Admin — School Portal" }] }),
+ component: AdminPage,
+});
+
+function AdminPage() {
+ const { roles } = useAuth();
+ if (!roles.includes("admin")) {
+ return
Admins only
;
+ }
+ return (
+
+
Admin
+
Manage users, roles, classes, and parent-student links.
+
+
+ Users & roles
+ Classes
+ Parent ↔ student
+
+
+
+
+
+
+ );
+}
+
+function UsersTab() {
+ const qc = useQueryClient();
+ const { data } = useQuery({
+ queryKey: ["admin-users"],
+ queryFn: async () => {
+ const { data: profiles } = await supabase.from("profiles").select("id, full_name, email");
+ const { data: roles } = await supabase.from("user_roles").select("user_id, role");
+ const rolesByUser: Record = {};
+ (roles ?? []).forEach((r) => { rolesByUser[r.user_id] = [...(rolesByUser[r.user_id] ?? []), r.role as Role]; });
+ return (profiles ?? []).map((p) => ({ ...p, roles: rolesByUser[p.id] ?? [] }));
+ },
+ });
+
+ const setRole = useMutation({
+ mutationFn: async ({ userId, role, on }: { userId: string; role: Role; on: boolean }) => {
+ if (on) {
+ const { error } = await supabase.from("user_roles").insert({ user_id: userId, role });
+ if (error && !error.message.includes("duplicate")) throw error;
+ } else {
+ const { error } = await supabase.from("user_roles").delete().eq("user_id", userId).eq("role", role);
+ if (error) throw error;
+ }
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
+ 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 |
+
+ {(data ?? []).map((u) => (
+
+ | {u.full_name || "—"} |
+ {u.email} |
+
+
+ {(["admin", "teacher", "parent"] as Role[]).map((r) => {
+ const on = u.roles.includes(r);
+ return ;
+ })}
+
+ |
+
+ ))}
+
+
+
+
+ );
+}
+
+function ClassesTab() {
+ const qc = useQueryClient();
+ const [name, setName] = useState("");
+ const [teacherId, setTeacherId] = useState("");
+
+ const { data: classes } = useQuery({
+ queryKey: ["admin-classes"],
+ queryFn: async () => {
+ const { data } = await supabase.from("classes").select("*").order("name");
+ const tids = Array.from(new Set((data ?? []).map((c) => c.teacher_id).filter(Boolean))) as string[];
+ const { data: profs } = tids.length ? await supabase.from("profiles").select("id, full_name").in("id", tids) : { data: [] };
+ const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
+ return (data ?? []).map((c) => ({ ...c, teacher_name: c.teacher_id ? map[c.teacher_id] : null }));
+ },
+ });
+ const { data: teachers } = useQuery({
+ queryKey: ["teachers"],
+ queryFn: async () => {
+ const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "teacher");
+ const ids = (r ?? []).map((x) => x.user_id);
+ if (ids.length === 0) return [];
+ const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
+ return data ?? [];
+ },
+ });
+
+ const add = useMutation({
+ mutationFn: async () => {
+ const { error } = await supabase.from("classes").insert({ name, teacher_id: teacherId || null });
+ if (error) throw error;
+ },
+ onSuccess: () => { setName(""); setTeacherId(""); qc.invalidateQueries({ queryKey: ["admin-classes"] }); toast.success("Class added"); },
+ onError: (e: Error) => toast.error(e.message),
+ });
+ const del = useMutation({
+ mutationFn: async (id: string) => { const { error } = await supabase.from("classes").delete().eq("id", id); if (error) throw error; },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-classes"] }),
+ });
+
+ return (
+
+
+ {(classes ?? []).map((c) => (
+
+
+
{c.name}
+
Teacher: {c.teacher_name ?? "unassigned"}
+
+
+
+ ))}
+ {classes?.length === 0 &&
No classes yet.
}
+
+
+
New class
+
+
setName(e.target.value)} />
+
+
+
+
+
+ );
+}
+
+function LinksTab() {
+ const qc = useQueryClient();
+ const [parentId, setParentId] = useState("");
+ const [studentId, setStudentId] = useState("");
+
+ const { data: links } = useQuery({
+ queryKey: ["admin-links"],
+ queryFn: async () => {
+ const { data } = await supabase.from("parent_students").select("id, parent_id, students(first_name, last_name)");
+ const ids = (data ?? []).map((d) => d.parent_id);
+ const { data: profs } = ids.length ? await supabase.from("profiles").select("id, full_name, email").in("id", ids) : { data: [] };
+ const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p]));
+ return (data ?? []).map((d) => ({ ...d, profile: map[d.parent_id] }));
+ },
+ });
+ const { data: parents } = useQuery({
+ queryKey: ["all-parents"],
+ queryFn: async () => {
+ const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "parent");
+ const ids = (r ?? []).map((x) => x.user_id);
+ if (ids.length === 0) return [];
+ const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
+ return data ?? [];
+ },
+ });
+ const { data: students } = useQuery({
+ queryKey: ["all-students-link"],
+ queryFn: async () => (await supabase.from("students").select("id, first_name, last_name").order("last_name")).data ?? [],
+ });
+
+ const add = useMutation({
+ mutationFn: async () => {
+ const { error } = await supabase.from("parent_students").insert({ parent_id: parentId, student_id: studentId });
+ if (error) throw error;
+ },
+ onSuccess: () => { setParentId(""); setStudentId(""); qc.invalidateQueries({ queryKey: ["admin-links"] }); toast.success("Linked"); },
+ onError: (e: Error) => toast.error(e.message),
+ });
+ const del = useMutation({
+ mutationFn: async (id: string) => { const { error } = await supabase.from("parent_students").delete().eq("id", id); if (error) throw error; },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-links"] }),
+ });
+
+ return (
+
+
+ {(links ?? []).map((l) => {
+ const p = l.profile as { full_name?: string; email?: string } | undefined;
+ const s = l.students as { first_name: string; last_name: string } | null;
+ return (
+
+ {p?.full_name || p?.email} → {s?.first_name} {s?.last_name}
+
+
+ );
+ })}
+ {links?.length === 0 &&
No links yet.
}
+
+
+
Link parent to student
+
+
+
+
+
+
+
+ );
+}