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:
@@ -14,6 +14,9 @@ dist-ssr
|
|||||||
# Supabase CLI local state
|
# Supabase CLI local state
|
||||||
supabase/.temp
|
supabase/.temp
|
||||||
supabase/.branches
|
supabase/.branches
|
||||||
|
|
||||||
|
# Runtime secrets (service role key etc.) — never commit
|
||||||
|
.env.secret
|
||||||
.output
|
.output
|
||||||
.vinxi
|
.vinxi
|
||||||
.tanstack/**
|
.tanstack/**
|
||||||
|
|||||||
+4
-3
@@ -11,14 +11,15 @@ services:
|
|||||||
image: info-share-spot:latest
|
image: info-share-spot:latest
|
||||||
container_name: info-share-spot
|
container_name: info-share-spot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Runtime secrets (SUPABASE_SERVICE_ROLE_KEY) live here — gitignored, not in the repo.
|
||||||
|
env_file:
|
||||||
|
- path: .env.secret
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
# SSR runtime vars (public-safe). Compose substitutes these from the .env
|
# SSR runtime vars (public-safe). Compose substitutes these from the .env
|
||||||
# file next to this compose file.
|
# file next to this compose file.
|
||||||
SUPABASE_URL: ${SUPABASE_URL}
|
SUPABASE_URL: ${SUPABASE_URL}
|
||||||
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
|
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
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -7,9 +7,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { createUserFn } from "@/lib/user-admin.functions";
|
||||||
|
import { genTempPassword } from "@/lib/temp-password";
|
||||||
|
|
||||||
type Role = "admin" | "teacher" | "parent";
|
type Role = "admin" | "teacher" | "parent";
|
||||||
|
|
||||||
@@ -68,8 +70,51 @@ function UsersTab() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
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 (
|
return (
|
||||||
<div className="bg-card border rounded-lg overflow-hidden mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm flex items-center gap-2"><UserPlus className="h-4 w-4" /> Add a user</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||||
|
<Input placeholder="Full name" value={nu.fullName} onChange={(e) => setNu({ ...nu, fullName: e.target.value })} />
|
||||||
|
<Input type="email" placeholder="Email" value={nu.email} onChange={(e) => setNu({ ...nu, email: e.target.value })} />
|
||||||
|
<Select value={nu.role} onValueChange={(v) => setNu({ ...nu, role: v as Role })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="teacher">Teacher</SelectItem><SelectItem value="parent">Parent</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => addUser.mutate()} disabled={!nu.fullName || !nu.email || addUser.isPending}>{addUser.isPending ? "Creating…" : "Create account"}</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Creates a login with a temporary password (shown once). They can change it after signing in. No email is sent.</p>
|
||||||
|
{created && (
|
||||||
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
|
||||||
|
<div className="font-medium">Account created — share these with {created.email}:</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 font-mono text-xs">
|
||||||
|
<span className="rounded bg-background border px-2 py-1">Email: {created.email}</span>
|
||||||
|
<span className="rounded bg-background border px-2 py-1">Password: {created.password}</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => { navigator.clipboard.writeText(`Email: ${created.email}\nPassword: ${created.password}`); toast.success("Copied"); }}><Copy className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Name</th><th className="text-left p-2">Email</th><th className="text-left p-2">Roles</th></tr></thead>
|
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Name</th><th className="text-left p-2">Email</th><th className="text-left p-2">Roles</th></tr></thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
@@ -90,6 +135,7 @@ function UsersTab() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { createUserFn } from "@/lib/user-admin.functions";
|
||||||
|
import { genTempPassword } from "@/lib/temp-password";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/students/$id")({
|
export const Route = createFileRoute("/_authenticated/students/$id")({
|
||||||
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
||||||
@@ -59,6 +61,7 @@ function StudentDetail() {
|
|||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const isAdmin = roles.includes("admin");
|
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({
|
const { data: student } = useQuery({
|
||||||
queryKey: ["student", id],
|
queryKey: ["student", id],
|
||||||
@@ -89,9 +92,9 @@ function StudentDetail() {
|
|||||||
<TabsTrigger value="ledger">Tuition</TabsTrigger>
|
<TabsTrigger value="ledger">Tuition</TabsTrigger>
|
||||||
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
|
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
|
||||||
<TabsContent value="family"><FamilyTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
<TabsContent value="family"><FamilyTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
|
||||||
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={canEdit} /></TabsContent>
|
||||||
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
|
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
|
||||||
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
||||||
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
|
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
|
||||||
@@ -142,7 +145,7 @@ function PhotoAvatar({ studentId, photoPath, canEdit }: { studentId: string; pho
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Profile ──────────────────────────────────────────────────────────────────
|
// ── Profile ──────────────────────────────────────────────────────────────────
|
||||||
function ProfileTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
|
function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit: boolean; isAdmin: boolean }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const { data: s } = useQuery({
|
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="Interests / hobbies" value={val("interests")} />
|
||||||
<ViewRow label="Other notes" value={val("other_info")} />
|
<ViewRow label="Other notes" value={val("other_info")} />
|
||||||
<ViewRow label="Photo release" value={c.photo_release ? "Granted" : "Not granted"} />
|
<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>
|
||||||
<Section title="Agreement">
|
<Section title="Agreement">
|
||||||
<ViewRow label="Signed by" value={val("agreement_signed_by")} />
|
<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("interests", "Interests / hobbies / activities")}
|
||||||
{A("other_info", "Anything else we should know")}
|
{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>
|
<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>
|
||||||
<Section title="Agreement">
|
<Section title="Agreement">
|
||||||
<div className="grid grid-cols-2 gap-3">{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}</div>
|
<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 ──────────────────────────────────────────────────────────
|
// ── 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 };
|
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 qc = useQueryClient();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const { data: guardians } = useQuery({
|
const { data: guardians } = useQuery({
|
||||||
@@ -312,6 +315,58 @@ function FamilyTab({ studentId, canEdit }: { studentId: string; canEdit: boolean
|
|||||||
</div>
|
</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="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." />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user