mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Add authentication
This commit is contained in:
235
src/app/(dashboard)/admin/page.tsx
Normal file
235
src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { requireAdmin } from "~/server/auth/utils";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { AdminUserTable } from "~/components/admin/admin-user-table";
|
||||
import { SystemStats } from "~/components/admin/system-stats";
|
||||
import { RoleManagement } from "~/components/admin/role-management";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await requireAdmin();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">
|
||||
System Administration
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
Manage users, roles, and system settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="destructive">Administrator</Badge>
|
||||
<span className="text-sm text-slate-600">
|
||||
{session.user.name ?? session.user.email}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/profile">Profile</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">← Back to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Dashboard Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{/* System Overview */}
|
||||
<div className="lg:col-span-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Current system status and statistics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SystemStats />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common admin tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
Create User
|
||||
</Button>
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
System Health
|
||||
</Button>
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Export Data
|
||||
</Button>
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button className="w-full justify-start" variant="outline" disabled>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Audit Logs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role Management */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Role Management</CardTitle>
|
||||
<CardDescription>System role definitions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RoleManagement />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* User Management */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage user accounts and role assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdminUserTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Warning */}
|
||||
<div className="mt-8">
|
||||
<Card className="border-yellow-200 bg-yellow-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-yellow-900">
|
||||
Administrator Access
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-yellow-800">
|
||||
You have full administrative access to this system. Please use these
|
||||
privileges responsibly. All administrative actions are logged for
|
||||
security purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
src/app/(dashboard)/profile/page.tsx
Normal file
318
src/app/(dashboard)/profile/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { formatRole, getRoleDescription } from "~/lib/auth-client";
|
||||
import { ProfileEditForm } from "~/components/profile/profile-edit-form";
|
||||
import { PasswordChangeForm } from "~/components/profile/password-change-form";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Profile</h1>
|
||||
<p className="text-slate-600">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-600">
|
||||
Welcome, {user.name ?? user.email}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/auth/signout">Sign Out</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">← Back to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Profile Information */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>
|
||||
Your personal account information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProfileEditForm
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>Change your account password</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordChangeForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Actions</CardTitle>
|
||||
<CardDescription>Manage your account settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Export Data</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Download all your research data and account information
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" disabled>
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-red-700">
|
||||
Delete Account
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Permanently delete your account and all associated data
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" disabled>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* User Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-lg font-semibold text-blue-600">
|
||||
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.name ?? "Unnamed User"}</p>
|
||||
<p className="text-sm text-slate-600">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">User ID</p>
|
||||
<p className="rounded bg-slate-100 p-2 font-mono text-xs break-all text-slate-600">
|
||||
{user.id}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Roles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Roles</CardTitle>
|
||||
<CardDescription>
|
||||
Your current system permissions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.roles && user.roles.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{user.roles.map((roleInfo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{formatRole(roleInfo.role)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">
|
||||
{getRoleDescription(roleInfo.role)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Granted {roleInfo.grantedAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500">
|
||||
Need additional permissions?{" "}
|
||||
<Link
|
||||
href="/contact"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Contact an administrator
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<svg
|
||||
className="h-6 w-6 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mb-1 text-sm font-medium text-slate-900">
|
||||
No Roles Assigned
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-slate-600">
|
||||
You don't have any system roles yet. Contact an
|
||||
administrator to get access to HRIStudio features.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/contact">Request Access</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href="/studies">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
My Studies
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
disabled
|
||||
>
|
||||
<Link href="/experiments">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
Experiments
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
disabled
|
||||
>
|
||||
<Link href="/wizard">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/app/(dashboard)/studies/page.tsx
Normal file
155
src/app/(dashboard)/studies/page.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
|
||||
export default async function StudiesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Studies</h1>
|
||||
<p className="text-slate-600">
|
||||
Manage your Human-Robot Interaction research studies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-600">
|
||||
Welcome, {session.user.name ?? session.user.email}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/auth/signout">Sign Out</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">← Back to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Studies Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Create New Study Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<svg
|
||||
className="h-8 w-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Create New Study</CardTitle>
|
||||
<CardDescription>Start a new HRI research study</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" disabled>
|
||||
Create Study
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Example Study Cards */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Robot Navigation Study</CardTitle>
|
||||
<CardDescription>
|
||||
Investigating user preferences for robot navigation patterns
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>Created: Dec 2024</span>
|
||||
<span>Status: Active</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" disabled>
|
||||
View Details
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="flex-1" disabled>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Social Robot Interaction</CardTitle>
|
||||
<CardDescription>
|
||||
Analyzing human responses to social robot behaviors
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>Created: Nov 2024</span>
|
||||
<span>Status: Draft</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" disabled>
|
||||
View Details
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="flex-1" disabled>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Empty State for No Studies */}
|
||||
<div className="mt-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||
<svg
|
||||
className="h-12 w-12 text-slate-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
Authentication Test Successful!
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
You're viewing a protected page. The authentication system is
|
||||
working correctly. This page will be replaced with actual study
|
||||
management functionality.
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">User ID: {session.user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/app/auth/signout/page.tsx
Normal file
111
src/app/auth/signout/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
|
||||
export default function SignOutPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If user is not logged in, redirect to home
|
||||
if (status === "loading") return; // Still loading
|
||||
if (!session) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await signOut({
|
||||
callbackUrl: "/",
|
||||
redirect: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error signing out:", error);
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null; // Will redirect via useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<h1 className="text-3xl font-bold text-slate-900">HRIStudio</h1>
|
||||
</Link>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Sign out of your research account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign Out Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign Out</CardTitle>
|
||||
<CardDescription>
|
||||
Are you sure you want to sign out of your account?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<p className="font-medium">
|
||||
Currently signed in as: {session.user.name ?? session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleSignOut}
|
||||
className="flex-1"
|
||||
disabled={isSigningOut}
|
||||
variant="destructive"
|
||||
>
|
||||
{isSigningOut ? "Signing out..." : "Sign Out"}
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="flex-1">
|
||||
<Link href="/">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-slate-500">
|
||||
<p>
|
||||
© 2024 HRIStudio. A platform for Human-Robot Interaction research.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
import { isAdmin } from "~/lib/auth-client";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -32,9 +33,19 @@ export default async function Home() {
|
||||
<span className="text-sm text-slate-600">
|
||||
Welcome, {session.user.name ?? session.user.email}
|
||||
</span>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/api/auth/signout">Sign Out</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{isAdmin(session) && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/admin">Admin</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/profile">Profile</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/auth/signout">Sign Out</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
@@ -56,14 +67,14 @@ export default async function Home() {
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Experiments</CardTitle>
|
||||
<CardTitle>Studies</CardTitle>
|
||||
<CardDescription>
|
||||
Design and manage your HRI experiments
|
||||
Manage your HRI research studies
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" asChild>
|
||||
<Link href="/experiments">View Experiments</Link>
|
||||
<Link href="/studies">View Studies</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
105
src/app/unauthorized/page.tsx
Normal file
105
src/app/unauthorized/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { auth } from "~/server/auth";
|
||||
|
||||
export default async function UnauthorizedPage() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<h1 className="text-3xl font-bold text-slate-900">HRIStudio</h1>
|
||||
</Link>
|
||||
<p className="mt-2 text-slate-600">Access Denied</p>
|
||||
</div>
|
||||
|
||||
{/* Unauthorized Card */}
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-red-100">
|
||||
<svg
|
||||
className="h-8 w-8 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Access Denied</CardTitle>
|
||||
<CardDescription>
|
||||
You don't have permission to access this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
<p className="font-medium">Insufficient Permissions</p>
|
||||
<p className="mt-1">
|
||||
This page requires additional privileges that your account
|
||||
doesn't have. Please contact your administrator to request
|
||||
access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session?.user && (
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<p className="font-medium">Current User:</p>
|
||||
<p>{session.user.name ?? session.user.email}</p>
|
||||
{session.user.roles && session.user.roles.length > 0 ? (
|
||||
<p className="mt-1">
|
||||
Roles: {session.user.roles.map((r) => r.role).join(", ")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1">No roles assigned</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button asChild className="flex-1">
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="flex-1">
|
||||
<Link href="/studies">My Studies</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-600">
|
||||
Need help?{" "}
|
||||
<Link
|
||||
href="/contact"
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Contact Support
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-slate-500">
|
||||
<p>
|
||||
© 2024 HRIStudio. A platform for Human-Robot Interaction research.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
161
src/components/profile/password-change-form.tsx
Normal file
161
src/components/profile/password-change-form.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(6, "Password must be at least 6 characters")
|
||||
.max(100, "Password is too long"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your new password"),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type PasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
export function PasswordChangeForm() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const form = useForm<PasswordFormData>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const changePassword = api.users.changePassword.useMutation({
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setShowForm(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error changing password:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: PasswordFormData) => {
|
||||
changePassword.mutate({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Change your account password for enhanced security
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowForm(true)} variant="outline">
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{changePassword.error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
<p className="font-medium">Error changing password</p>
|
||||
<p>{changePassword.error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changePassword.isSuccess && (
|
||||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700">
|
||||
<p className="font-medium">Password changed successfully</p>
|
||||
<p>Your password has been updated.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current Password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...form.register("currentPassword")}
|
||||
placeholder="Enter your current password"
|
||||
disabled={changePassword.isPending}
|
||||
/>
|
||||
{form.formState.errors.currentPassword && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New Password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
{...form.register("newPassword")}
|
||||
placeholder="Enter your new password"
|
||||
disabled={changePassword.isPending}
|
||||
/>
|
||||
{form.formState.errors.newPassword && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
{...form.register("confirmPassword")}
|
||||
placeholder="Confirm your new password"
|
||||
disabled={changePassword.isPending}
|
||||
/>
|
||||
{form.formState.errors.confirmPassword && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={changePassword.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={changePassword.isPending}>
|
||||
{changePassword.isPending ? "Changing..." : "Change Password"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
148
src/components/profile/profile-edit-form.tsx
Normal file
148
src/components/profile/profile-edit-form.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
|
||||
interface ProfileEditFormProps {
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function ProfileEditForm({ user }: ProfileEditFormProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const updateProfile = api.users.update.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsEditing(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error updating profile:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: ProfileFormData) => {
|
||||
updateProfile.mutate({
|
||||
id: user.id,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-slate-700">Name</Label>
|
||||
<p className="mt-1 text-sm text-slate-900">
|
||||
{user.name ?? "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-slate-700">Email</Label>
|
||||
<p className="mt-1 text-sm text-slate-900">
|
||||
{user.email ?? "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setIsEditing(true)} variant="outline">
|
||||
Edit Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{updateProfile.error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
<p className="font-medium">Error updating profile</p>
|
||||
<p>{updateProfile.error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...form.register("name")}
|
||||
placeholder="Enter your full name"
|
||||
disabled={updateProfile.isPending}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
placeholder="Enter your email address"
|
||||
disabled={updateProfile.isPending}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={updateProfile.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateProfile.isPending}>
|
||||
{updateProfile.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
164
src/lib/auth-client.ts
Normal file
164
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// Client-side role utilities without database imports
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
// Role types from schema
|
||||
export type SystemRole = "administrator" | "researcher" | "wizard" | "observer";
|
||||
export type StudyRole = "owner" | "researcher" | "wizard" | "observer";
|
||||
|
||||
/**
|
||||
* Check if the current user has a specific system role
|
||||
*/
|
||||
export function hasRole(session: Session | null, role: SystemRole): boolean {
|
||||
if (!session?.user?.roles) return false;
|
||||
return session.user.roles.some((userRole) => userRole.role === role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an administrator
|
||||
*/
|
||||
export function isAdmin(session: Session | null): boolean {
|
||||
return hasRole(session, "administrator");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a researcher or admin
|
||||
*/
|
||||
export function isResearcher(session: Session | null): boolean {
|
||||
return hasRole(session, "researcher") || isAdmin(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a wizard or admin
|
||||
*/
|
||||
export function isWizard(session: Session | null): boolean {
|
||||
return hasRole(session, "wizard") || isAdmin(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has any of the specified roles
|
||||
*/
|
||||
export function hasAnyRole(session: Session | null, roles: SystemRole[]): boolean {
|
||||
if (!session?.user?.roles) return false;
|
||||
return session.user.roles.some((userRole) =>
|
||||
roles.includes(userRole.role)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user owns or has admin access to a resource
|
||||
*/
|
||||
export function canAccessResource(
|
||||
session: Session | null,
|
||||
resourceOwnerId: string
|
||||
): boolean {
|
||||
if (!session?.user) return false;
|
||||
|
||||
// Admin can access anything
|
||||
if (isAdmin(session)) return true;
|
||||
|
||||
// Owner can access their own resources
|
||||
if (session.user.id === resourceOwnerId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format role for display
|
||||
*/
|
||||
export function formatRole(role: SystemRole): string {
|
||||
const roleMap: Record<SystemRole, string> = {
|
||||
administrator: "Administrator",
|
||||
researcher: "Researcher",
|
||||
wizard: "Wizard",
|
||||
observer: "Observer",
|
||||
};
|
||||
|
||||
return roleMap[role] || role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role description
|
||||
*/
|
||||
export function getRoleDescription(role: SystemRole): string {
|
||||
const descriptions: Record<SystemRole, string> = {
|
||||
administrator: "Full system access and user management",
|
||||
researcher: "Can create and manage studies and experiments",
|
||||
wizard: "Can control robots during trials and experiments",
|
||||
observer: "Read-only access to studies and trial data",
|
||||
};
|
||||
|
||||
return descriptions[role] || "Unknown role";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles for assignment
|
||||
*/
|
||||
export function getAvailableRoles(): Array<{
|
||||
value: SystemRole;
|
||||
label: string;
|
||||
description: string;
|
||||
}> {
|
||||
const roles: SystemRole[] = ["administrator", "researcher", "wizard", "observer"];
|
||||
|
||||
return roles.map((role) => ({
|
||||
value: role,
|
||||
label: formatRole(role),
|
||||
description: getRoleDescription(role),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role badge color classes
|
||||
*/
|
||||
export function getRoleColor(role: SystemRole): string {
|
||||
switch (role) {
|
||||
case "administrator":
|
||||
return "bg-red-100 text-red-800 border-red-200";
|
||||
case "researcher":
|
||||
return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
case "wizard":
|
||||
return "bg-purple-100 text-purple-800 border-purple-200";
|
||||
case "observer":
|
||||
return "bg-green-100 text-green-800 border-green-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role permissions list (client-side mock data)
|
||||
*/
|
||||
export function getRolePermissions(role: SystemRole): string[] {
|
||||
const rolePermissions: Record<SystemRole, string[]> = {
|
||||
administrator: [
|
||||
"Full system access",
|
||||
"User management",
|
||||
"System configuration",
|
||||
"Audit logs access",
|
||||
"Data export/import",
|
||||
],
|
||||
researcher: [
|
||||
"Create/manage studies",
|
||||
"Design experiments",
|
||||
"Analyze trial data",
|
||||
"Manage participants",
|
||||
"Export research data",
|
||||
],
|
||||
wizard: [
|
||||
"Control robots during trials",
|
||||
"Execute experiment steps",
|
||||
"Monitor trial progress",
|
||||
"Record interventions",
|
||||
"Access wizard interface",
|
||||
],
|
||||
observer: [
|
||||
"View studies and experiments",
|
||||
"Watch trial executions",
|
||||
"Access analysis reports",
|
||||
"View participant data",
|
||||
"Read-only system access",
|
||||
],
|
||||
};
|
||||
|
||||
return rolePermissions[role] || [];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, count, eq, ilike, or, type SQL } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
import {
|
||||
adminProcedure,
|
||||
@@ -199,6 +200,58 @@ export const usersRouter = createTRPCRouter({
|
||||
return updatedUser;
|
||||
}),
|
||||
|
||||
changePassword: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(6, "Password must be at least 6 characters"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { currentPassword, newPassword } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get current user with password
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
currentPassword,
|
||||
user.password,
|
||||
);
|
||||
if (!isValidPassword) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Current password is incorrect",
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedNewPassword,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
assignRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type DefaultSession, type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
@@ -16,15 +17,20 @@ declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
roles: Array<{
|
||||
role: "administrator" | "researcher" | "wizard" | "observer";
|
||||
grantedAt: Date;
|
||||
grantedBy: string | null;
|
||||
}>;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
// interface User {
|
||||
// // ...other properties
|
||||
// // role: UserRole;
|
||||
// }
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +39,14 @@ declare module "next-auth" {
|
||||
* @see https://next-auth.js.org/configuration/options
|
||||
*/
|
||||
export const authConfig = {
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
error: "/auth/error",
|
||||
},
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
@@ -41,38 +55,37 @@ export const authConfig = {
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
})
|
||||
.safeParse(credentials);
|
||||
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, credentials.email as string),
|
||||
where: eq(users.email, parsed.data.email),
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
return null;
|
||||
}
|
||||
if (!user?.password) return null;
|
||||
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
parsed.data.password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return null;
|
||||
}
|
||||
if (!isValidPassword) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
jwt: ({ token, user }) => {
|
||||
if (user) {
|
||||
@@ -80,12 +93,41 @@ export const authConfig = {
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session: ({ session, token }) => ({
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id as string,
|
||||
},
|
||||
}),
|
||||
session: async ({ session, token }) => {
|
||||
if (token.id) {
|
||||
// Fetch user roles from database
|
||||
const userWithRoles = await db.query.users.findFirst({
|
||||
where: eq(users.id, token.id as string),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
grantedByUser: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id as string,
|
||||
roles:
|
||||
userWithRoles?.systemRoles?.map((sr) => ({
|
||||
role: sr.role,
|
||||
grantedAt: sr.grantedAt,
|
||||
grantedBy: sr.grantedBy,
|
||||
})) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
230
src/server/auth/utils.ts
Normal file
230
src/server/auth/utils.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { auth } from "./index";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "~/server/db";
|
||||
import { users, userSystemRoles } from "~/server/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
// Role types from schema
|
||||
export type SystemRole = "administrator" | "researcher" | "wizard" | "observer";
|
||||
export type StudyRole = "owner" | "researcher" | "wizard" | "observer";
|
||||
|
||||
/**
|
||||
* Get the current session or redirect to login
|
||||
*/
|
||||
export async function requireAuth() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session without redirecting
|
||||
*/
|
||||
export async function getSession() {
|
||||
return await auth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a specific system role
|
||||
*/
|
||||
export function hasRole(session: Session | null, role: SystemRole): boolean {
|
||||
if (!session?.user?.roles) return false;
|
||||
return session.user.roles.some((userRole) => userRole.role === role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an administrator
|
||||
*/
|
||||
export function isAdmin(session: Session | null): boolean {
|
||||
return hasRole(session, "administrator");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a researcher or admin
|
||||
*/
|
||||
export function isResearcher(session: Session | null): boolean {
|
||||
return hasRole(session, "researcher") || isAdmin(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a wizard or admin
|
||||
*/
|
||||
export function isWizard(session: Session | null): boolean {
|
||||
return hasRole(session, "wizard") || isAdmin(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has any of the specified roles
|
||||
*/
|
||||
export function hasAnyRole(session: Session | null, roles: SystemRole[]): boolean {
|
||||
if (!session?.user?.roles) return false;
|
||||
return session.user.roles.some((userRole) =>
|
||||
roles.includes(userRole.role)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role or redirect
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const session = await requireAuth();
|
||||
if (!isAdmin(session)) {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require researcher role or redirect
|
||||
*/
|
||||
export async function requireResearcher() {
|
||||
const session = await requireAuth();
|
||||
if (!isResearcher(session)) {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user roles from database
|
||||
*/
|
||||
export async function getUserRoles(userId: string) {
|
||||
const userWithRoles = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
grantedByUser: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return userWithRoles?.systemRoles ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a system role to a user
|
||||
*/
|
||||
export async function grantRole(
|
||||
userId: string,
|
||||
role: SystemRole,
|
||||
grantedBy: string
|
||||
) {
|
||||
// Check if user already has this role
|
||||
const existingRole = await db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRole) {
|
||||
throw new Error(`User already has role: ${role}`);
|
||||
}
|
||||
|
||||
// Grant the role
|
||||
const newRole = await db
|
||||
.insert(userSystemRoles)
|
||||
.values({
|
||||
userId,
|
||||
role,
|
||||
grantedBy,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newRole[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a system role from a user
|
||||
*/
|
||||
export async function revokeRole(userId: string, role: SystemRole) {
|
||||
const deletedRole = await db
|
||||
.delete(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (deletedRole.length === 0) {
|
||||
throw new Error(`User does not have role: ${role}`);
|
||||
}
|
||||
|
||||
return deletedRole[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user owns or has admin access to a resource
|
||||
*/
|
||||
export function canAccessResource(
|
||||
session: Session | null,
|
||||
resourceOwnerId: string
|
||||
): boolean {
|
||||
if (!session?.user) return false;
|
||||
|
||||
// Admin can access anything
|
||||
if (isAdmin(session)) return true;
|
||||
|
||||
// Owner can access their own resources
|
||||
if (session.user.id === resourceOwnerId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format role for display
|
||||
*/
|
||||
export function formatRole(role: SystemRole): string {
|
||||
const roleMap: Record<SystemRole, string> = {
|
||||
administrator: "Administrator",
|
||||
researcher: "Researcher",
|
||||
wizard: "Wizard",
|
||||
observer: "Observer",
|
||||
};
|
||||
|
||||
return roleMap[role] || role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role description
|
||||
*/
|
||||
export function getRoleDescription(role: SystemRole): string {
|
||||
const descriptions: Record<SystemRole, string> = {
|
||||
administrator: "Full system access and user management",
|
||||
researcher: "Can create and manage studies and experiments",
|
||||
wizard: "Can control robots during trials and experiments",
|
||||
observer: "Read-only access to studies and trial data",
|
||||
};
|
||||
|
||||
return descriptions[role] || "Unknown role";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles for assignment
|
||||
*/
|
||||
export function getAvailableRoles(): Array<{
|
||||
value: SystemRole;
|
||||
label: string;
|
||||
description: string;
|
||||
}> {
|
||||
const roles: SystemRole[] = ["administrator", "researcher", "wizard", "observer"];
|
||||
|
||||
return roles.map((role) => ({
|
||||
value: role,
|
||||
label: formatRole(role),
|
||||
description: getRoleDescription(role),
|
||||
}));
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
varchar
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { type AdapterAccount } from "next-auth/adapters";
|
||||
|
||||
@@ -23,7 +23,7 @@ import { type AdapterAccount } from "next-auth/adapters";
|
||||
*
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
export const createTable = pgTableCreator((name) => `hristudio_${name}`);
|
||||
export const createTable = pgTableCreator((name) => `hs_${name}`);
|
||||
|
||||
// Enums
|
||||
export const systemRoleEnum = pgEnum("system_role", [
|
||||
|
||||
Reference in New Issue
Block a user