mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Add authentication
This commit is contained in:
359
src/components/admin/admin-user-table.tsx
Normal file
359
src/components/admin/admin-user-table.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatRole, getAvailableRoles } from "~/lib/auth-client";
|
||||
import type { SystemRole } from "~/lib/auth-client";
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
createdAt: Date;
|
||||
roles: SystemRole[];
|
||||
}
|
||||
|
||||
export function AdminUserTable() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | "">("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedUser, setSelectedUser] = useState<UserWithRoles | null>(null);
|
||||
const [roleToAssign, setRoleToAssign] = useState<SystemRole | "">("");
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = api.users.list.useQuery({
|
||||
page,
|
||||
limit: 10,
|
||||
search: search || undefined,
|
||||
role: selectedRole || undefined,
|
||||
});
|
||||
|
||||
const assignRole = api.users.assignRole.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
setSelectedUser(null);
|
||||
setRoleToAssign("");
|
||||
},
|
||||
});
|
||||
|
||||
const removeRole = api.users.removeRole.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAssignRole = () => {
|
||||
if (!selectedUser || !roleToAssign) return;
|
||||
|
||||
assignRole.mutate({
|
||||
userId: selectedUser.id,
|
||||
role: roleToAssign,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveRole = (userId: string, role: SystemRole) => {
|
||||
removeRole.mutate({
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
};
|
||||
|
||||
const availableRoles = getAvailableRoles();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">Search Users</Label>
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-48">
|
||||
<Label htmlFor="role-filter">Filter by Role</Label>
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(value) => setSelectedRole(value as SystemRole | "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All roles</SelectItem>
|
||||
{availableRoles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-slate-50">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-slate-700">
|
||||
User
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-slate-700">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-slate-700">
|
||||
Roles
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-slate-700">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-slate-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{usersData?.users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm font-semibold text-blue-600">
|
||||
{(user.name ?? user.email).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">
|
||||
{user.name ?? "Unnamed User"}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
ID: {user.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-slate-900">{user.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.length > 0 ? (
|
||||
user.roles.map((role) => (
|
||||
<div key={role} className="flex items-center gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatRole(role)}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => handleRemoveRole(user.id, role)}
|
||||
disabled={removeRole.isPending}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">No roles</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-slate-600">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage User Roles</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign or remove roles for {user.name ?? user.email}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="role-select">Assign Role</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={roleToAssign}
|
||||
onValueChange={(value) =>
|
||||
setRoleToAssign(value as SystemRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRoles
|
||||
.filter(
|
||||
(role) =>
|
||||
!user.roles.includes(role.value),
|
||||
)
|
||||
.map((role) => (
|
||||
<SelectItem
|
||||
key={role.value}
|
||||
value={role.value}
|
||||
>
|
||||
{role.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleAssignRole}
|
||||
disabled={!roleToAssign || assignRole.isPending}
|
||||
>
|
||||
{assignRole.isPending
|
||||
? "Assigning..."
|
||||
: "Assign"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Current Roles</Label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{user.roles.length > 0 ? (
|
||||
user.roles.map((role) => (
|
||||
<div
|
||||
key={role}
|
||||
className="flex items-center justify-between rounded-md border p-2"
|
||||
>
|
||||
<div>
|
||||
<Badge variant="secondary">
|
||||
{formatRole(role)}
|
||||
</Badge>
|
||||
<p className="mt-1 text-xs text-slate-600">
|
||||
{
|
||||
availableRoles.find(
|
||||
(r) => r.value === role,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRemoveRole(user.id, role)
|
||||
}
|
||||
disabled={removeRole.isPending}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
No roles assigned
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{usersData && usersData.pagination.pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
Showing {usersData.users.length} of {usersData.pagination.total}{" "}
|
||||
users
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-3 text-sm">
|
||||
{page} of {usersData.pagination.pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === usersData.pagination.pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Messages */}
|
||||
{assignRole.error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
<p className="font-medium">Error assigning role</p>
|
||||
<p>{assignRole.error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{removeRole.error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
<p className="font-medium">Error removing role</p>
|
||||
<p>{removeRole.error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/admin/role-management.tsx
Normal file
119
src/components/admin/role-management.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
getAvailableRoles,
|
||||
getRolePermissions,
|
||||
getRoleColor,
|
||||
} from "~/lib/auth-client";
|
||||
|
||||
export function RoleManagement() {
|
||||
const availableRoles = getAvailableRoles();
|
||||
|
||||
// Mock data for role statistics - in a real implementation, this would come from an API
|
||||
const roleStats = {
|
||||
administrator: 2,
|
||||
researcher: 15,
|
||||
wizard: 8,
|
||||
observer: 12,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-slate-900">
|
||||
System Roles Overview
|
||||
</h3>
|
||||
<p className="text-xs text-slate-600">
|
||||
Roles define user permissions and access levels within HRIStudio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{availableRoles.map((role) => (
|
||||
<Card key={role.value} className="border-l-4 border-l-transparent">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getRoleColor(role.value)} text-xs`}
|
||||
>
|
||||
{role.label}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<span className="text-xs text-slate-500">
|
||||
{roleStats[role.value as keyof typeof roleStats] || 0} users
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-slate-600">{role.description}</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-medium text-slate-700">
|
||||
Key Permissions:
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{getRolePermissions(role.value)
|
||||
?.slice(0, 3)
|
||||
.map((permission, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-slate-400"></div>
|
||||
<span className="text-xs text-slate-600">
|
||||
{permission}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{getRolePermissions(role.value)?.length > 3 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-slate-300"></div>
|
||||
<span className="text-xs text-slate-500">
|
||||
+{getRolePermissions(role.value).length - 3} more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100">
|
||||
<svg
|
||||
className="h-3 w-3 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-blue-900">
|
||||
Role Hierarchy
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-blue-800">
|
||||
Administrator has access to all features. Users can have multiple
|
||||
roles for different access levels.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/components/admin/system-stats.tsx
Normal file
182
src/components/admin/system-stats.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
export function SystemStats() {
|
||||
// TODO: Implement admin.getSystemStats API endpoint
|
||||
// const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
||||
const isLoading = false;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="h-4 w-20 rounded bg-slate-200"></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 h-8 w-12 rounded bg-slate-200"></div>
|
||||
<div className="h-3 w-24 rounded bg-slate-200"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mock data for now since we don't have the actual admin router implemented
|
||||
const mockStats = {
|
||||
totalUsers: 42,
|
||||
totalStudies: 15,
|
||||
totalExperiments: 38,
|
||||
totalTrials: 127,
|
||||
activeTrials: 3,
|
||||
systemHealth: "healthy",
|
||||
uptime: "7 days, 14 hours",
|
||||
storageUsed: "2.3 GB",
|
||||
};
|
||||
|
||||
const displayStats = mockStats;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total Users */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Total Users
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{displayStats.totalUsers}</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
All roles
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Total Studies */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Studies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{displayStats.totalStudies}</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Total Experiments */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Experiments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{displayStats.totalExperiments}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Published
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Total Trials */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Trials
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{displayStats.totalTrials}</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{displayStats.activeTrials} running
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Health */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
System Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-3 w-3 items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
{displayStats.systemHealth === "healthy" ? "Healthy" : "Issues"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
All services operational
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Uptime */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Uptime
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-medium">{displayStats.uptime}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">Since last restart</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Storage Used
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-medium">{displayStats.storageUsed}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">Media & database</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-slate-600">2 trials started today</div>
|
||||
<div className="text-xs text-slate-600">1 new user registered</div>
|
||||
<div className="text-xs text-slate-600">
|
||||
3 experiments published
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user