Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
import { createFileRoute } from "@tanstack/react-router";
|
|
import { useEffect, useState } from "react";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth, type AppRole } from "@/lib/auth";
|
|
import { ProtectedLayout } from "@/components/protected-layout";
|
|
import { PageContainer, PageHeader } from "@/components/app-shell";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Loader2, UserPlus, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
export const Route = createFileRoute("/admin/users")({
|
|
component: () => (
|
|
<ProtectedLayout adminOnly>
|
|
<UsersPage />
|
|
</ProtectedLayout>
|
|
),
|
|
});
|
|
|
|
interface UserRow {
|
|
id: string;
|
|
email: string;
|
|
full_name: string;
|
|
created_at: string;
|
|
hourly_rate: number | null;
|
|
role: AppRole | null;
|
|
}
|
|
|
|
const ROLE_LABEL: Record<AppRole, string> = {
|
|
admin: "Administrator",
|
|
attorney: "Attorney",
|
|
paralegal: "Paralegal",
|
|
legal_clerk: "Legal Clerk",
|
|
staff: "Staff",
|
|
};
|
|
|
|
function UsersPage() {
|
|
const { user: currentUser } = useAuth();
|
|
const [users, setUsers] = useState<UserRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [inviteOpen, setInviteOpen] = useState(false);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
const [{ data: profiles }, { data: rolesData }] = await Promise.all([
|
|
supabase.from("profiles").select("id, email, full_name, created_at, hourly_rate"),
|
|
supabase.from("user_roles").select("user_id, role"),
|
|
]);
|
|
const roleMap = new Map<string, AppRole>();
|
|
(rolesData ?? []).forEach((r) => roleMap.set(r.user_id, r.role as AppRole));
|
|
const rows: UserRow[] = (profiles ?? []).map((p: any) => ({
|
|
id: p.id,
|
|
email: p.email,
|
|
full_name: p.full_name,
|
|
created_at: p.created_at,
|
|
hourly_rate: p.hourly_rate,
|
|
role: roleMap.get(p.id) ?? null,
|
|
}));
|
|
rows.sort((a, b) => a.email.localeCompare(b.email));
|
|
setUsers(rows);
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const handleRoleChange = async (userId: string, role: AppRole) => {
|
|
const { error } = await supabase.functions.invoke("admin-update-role", {
|
|
body: { user_id: userId, role },
|
|
});
|
|
if (error) {
|
|
toast.error("Could not update role", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("Role updated");
|
|
load();
|
|
};
|
|
|
|
const handleDelete = async (userId: string) => {
|
|
const { error } = await supabase.functions.invoke("admin-delete-user", {
|
|
body: { user_id: userId },
|
|
});
|
|
if (error) {
|
|
toast.error("Could not delete user", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("User deleted");
|
|
load();
|
|
};
|
|
|
|
const handleRateSave = async (userId: string, rate: number | null) => {
|
|
const { error } = await supabase
|
|
.from("profiles")
|
|
.update({ hourly_rate: rate })
|
|
.eq("id", userId);
|
|
if (error) {
|
|
toast.error("Could not save rate", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("Rate updated");
|
|
load();
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Users"
|
|
description="Invite team members and manage their access."
|
|
actions={
|
|
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<UserPlus className="h-4 w-4 mr-2" />
|
|
Invite user
|
|
</Button>
|
|
</DialogTrigger>
|
|
<InviteDialog
|
|
onCreated={() => {
|
|
setInviteOpen(false);
|
|
load();
|
|
}}
|
|
/>
|
|
</Dialog>
|
|
}
|
|
/>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="font-serif">Team members</CardTitle>
|
|
<CardDescription>{users.length} total</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground py-8 text-center">No users yet.</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead className="w-[140px]">Hourly rate</TableHead>
|
|
<TableHead className="w-[80px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{users.map((u) => {
|
|
const isSelf = u.id === currentUser?.id;
|
|
return (
|
|
<TableRow key={u.id}>
|
|
<TableCell className="font-medium">
|
|
{u.full_name || <span className="text-muted-foreground">—</span>}
|
|
{isSelf && (
|
|
<Badge variant="secondary" className="ml-2">
|
|
You
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
|
<TableCell>
|
|
<Select
|
|
value={u.role ?? undefined}
|
|
onValueChange={(v) => handleRoleChange(u.id, v as AppRole)}
|
|
disabled={isSelf}
|
|
>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder="No role" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{ROLE_LABEL[r]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<RateInput
|
|
value={u.hourly_rate}
|
|
onSave={(v) => handleRateSave(u.id, v)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
{!isSelf && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="ghost" size="icon">
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete user?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will permanently remove {u.email} and revoke their access.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => handleDelete(u.id)}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function RateInput({
|
|
value,
|
|
onSave,
|
|
}: {
|
|
value: number | null;
|
|
onSave: (v: number | null) => void | Promise<void>;
|
|
}) {
|
|
const [text, setText] = useState(value != null ? String(value) : "");
|
|
useEffect(() => setText(value != null ? String(value) : ""), [value]);
|
|
const dirty = text !== (value != null ? String(value) : "");
|
|
|
|
const commit = () => {
|
|
if (!dirty) return;
|
|
if (text.trim() === "") return onSave(null);
|
|
const n = parseFloat(text);
|
|
if (Number.isNaN(n) || n < 0) {
|
|
toast.error("Invalid rate");
|
|
setText(value != null ? String(value) : "");
|
|
return;
|
|
}
|
|
onSave(n);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground">$</span>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
}}
|
|
placeholder="—"
|
|
className="h-8 w-24"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">/hr</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function InviteDialog({ onCreated }: { onCreated: () => void }) {
|
|
const [email, setEmail] = useState("");
|
|
const [fullName, setFullName] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [role, setRole] = useState<AppRole>("attorney");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const onSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
const { error } = await supabase.functions.invoke("admin-invite-user", {
|
|
body: { email: email.trim(), password, full_name: fullName.trim(), role },
|
|
});
|
|
setSubmitting(false);
|
|
if (error) {
|
|
toast.error("Could not create user", { description: error.message });
|
|
return;
|
|
}
|
|
toast.success("User created", {
|
|
description: "Share the temporary password securely with them.",
|
|
});
|
|
setEmail("");
|
|
setFullName("");
|
|
setPassword("");
|
|
setRole("attorney");
|
|
onCreated();
|
|
};
|
|
|
|
return (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="font-serif">Invite user</DialogTitle>
|
|
<DialogDescription>
|
|
Create an account with a temporary password. Share it with them through a secure channel.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invite-name">Full name</Label>
|
|
<Input
|
|
id="invite-name"
|
|
value={fullName}
|
|
onChange={(e) => setFullName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invite-email">Email</Label>
|
|
<Input
|
|
id="invite-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invite-password">Temporary password</Label>
|
|
<Input
|
|
id="invite-password"
|
|
type="text"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
minLength={10}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Min 10 characters. The user can change it after first sign-in.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Role</Label>
|
|
<Select value={role} onValueChange={(v) => setRole(v as AppRole)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(Object.keys(ROLE_LABEL) as AppRole[]).map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{ROLE_LABEL[r]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" disabled={submitting}>
|
|
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Create user
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
);
|
|
}
|