mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
migrate: replace NextAuth.js with Better Auth
- Install better-auth and @better-auth/drizzle-adapter - Create src/lib/auth.ts with Better Auth configuration using bcrypt - Update database schema: change auth table IDs from uuid to text - Update route handler from /api/auth/[...nextauth] to /api/auth/[...all] - Update tRPC context and middleware for Better Auth session handling - Update client components to use Better Auth APIs (signIn, signOut) - Update seed script with text-based IDs and correct account schema - Fix type errors in wizard components (robotId, optional chaining) - Fix API paths: api.robots.initialize -> api.robots.plugins.initialize - Update auth router to use text IDs for Better Auth compatibility Note: Auth tables were reset - users will need to re-register.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "~/components/ui/sidebar";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { AppSidebar } from "~/components/dashboard/app-sidebar";
|
||||
import { auth } from "~/server/auth";
|
||||
import { auth } from "~/lib/auth";
|
||||
import {
|
||||
BreadcrumbProvider,
|
||||
BreadcrumbDisplay,
|
||||
@@ -22,16 +22,15 @@ interface DashboardLayoutProps {
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: DashboardLayoutProps) {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const userRole =
|
||||
typeof session.user.roles?.[0] === "string"
|
||||
? session.user.roles[0]
|
||||
: (session.user.roles?.[0]?.role ?? "observer");
|
||||
const userRole = "researcher"; // Default role for dashboard access
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
||||
|
||||
@@ -16,19 +16,10 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { formatRole, getRoleDescription } from "~/lib/auth-client";
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
Download,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
UserCog,
|
||||
Mail,
|
||||
Fingerprint,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { User, Shield, Download, Trash2, Lock, UserCog } from "lucide-react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ProfileUser {
|
||||
id: string;
|
||||
@@ -37,7 +28,8 @@ interface ProfileUser {
|
||||
image: string | null;
|
||||
roles?: Array<{
|
||||
role: "administrator" | "researcher" | "wizard" | "observer";
|
||||
grantedAt: string | Date;
|
||||
grantedAt: Date;
|
||||
grantedBy: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -213,14 +205,20 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { data: session, status } = useSession();
|
||||
const { data: session, isPending } = useSession();
|
||||
const { data: userData, isPending: isUserPending } = api.auth.me.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!session?.user,
|
||||
},
|
||||
);
|
||||
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Profile" },
|
||||
]);
|
||||
|
||||
if (status === "loading") {
|
||||
if (isPending || isUserPending) {
|
||||
return (
|
||||
<div className="text-muted-foreground animate-pulse p-8">
|
||||
Loading profile...
|
||||
@@ -232,7 +230,13 @@ export default function ProfilePage() {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
const user: ProfileUser = {
|
||||
id: session.user.id,
|
||||
name: userData?.name ?? session.user.name ?? null,
|
||||
email: userData?.email ?? session.user.email,
|
||||
image: userData?.image ?? session.user.image ?? null,
|
||||
roles: userData?.systemRoles as ProfileUser["roles"],
|
||||
};
|
||||
|
||||
return <ProfileContent user={user} />;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
@@ -99,6 +99,9 @@ export default function ExperimentDetailPage({
|
||||
params,
|
||||
}: ExperimentDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const { data: userData } = api.auth.me.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
});
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -181,7 +184,7 @@ export default function ExperimentDetailPage({
|
||||
const description = experiment.description;
|
||||
|
||||
// Check if user can edit this experiment
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const userRoles = userData?.roles ?? [];
|
||||
const canEdit =
|
||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
FileText,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "~/components/ui/entity-view";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface StudyDetailPageProps {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { WizardView } from "~/components/trials/views/WizardView";
|
||||
import { ObserverView } from "~/components/trials/views/ObserverView";
|
||||
import { ParticipantView } from "~/components/trials/views/ParticipantView";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
|
||||
function WizardPageContent() {
|
||||
const params = useParams();
|
||||
@@ -25,6 +25,11 @@ function WizardPageContent() {
|
||||
const { study } = useSelectedStudyDetails();
|
||||
const { data: session } = useSession();
|
||||
|
||||
// Get user roles
|
||||
const { data: userData } = api.auth.me.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
});
|
||||
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
@@ -67,7 +72,7 @@ function WizardPageContent() {
|
||||
}
|
||||
|
||||
// Default role logic based on user
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const userRole = userData?.roles?.[0] ?? "observer";
|
||||
if (userRole === "administrator" || userRole === "researcher") {
|
||||
return "wizard";
|
||||
}
|
||||
@@ -188,6 +193,7 @@ function WizardPageContent() {
|
||||
name: trial.experiment.name,
|
||||
description: trial.experiment.description,
|
||||
studyId: trial.experiment.studyId,
|
||||
robotId: trial.experiment.robotId,
|
||||
},
|
||||
participant: {
|
||||
id: trial.participant.id,
|
||||
|
||||
4
src/app/api/auth/[...all]/route.ts
Executable file
4
src/app/api/auth/[...all]/route.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "~/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handlers } from "~/server/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
generateFileKey,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
uploadFile,
|
||||
validateFile,
|
||||
} from "~/lib/storage/minio";
|
||||
import { auth } from "~/server/auth";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
experiments,
|
||||
@@ -28,7 +29,9 @@ const uploadSchema = z.object({
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
@@ -181,7 +184,9 @@ export async function POST(request: NextRequest) {
|
||||
// Generate presigned upload URL for direct client uploads
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { signIn } from "~/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -37,22 +37,21 @@ export default function SignInPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
const result = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password");
|
||||
if (result.error) {
|
||||
setError(result.error.message || "Invalid email or password");
|
||||
} else {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "An error occurred. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { signOut, useSession } from "~/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -14,33 +14,29 @@ import {
|
||||
} from "~/components/ui/card";
|
||||
|
||||
export default function SignOutPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const { data: session, isPending } = 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) {
|
||||
if (!isPending && !session) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
}, [session, isPending, router]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await signOut({
|
||||
callbackUrl: "/",
|
||||
redirect: true,
|
||||
});
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Error signing out:", error);
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
if (isPending) {
|
||||
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">
|
||||
@@ -52,7 +48,7 @@ export default function SignOutPage() {
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null; // Will redirect via useEffect
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -80,7 +76,7 @@ export default function SignOutPage() {
|
||||
<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}
|
||||
{session.user?.name ?? session.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +99,8 @@ export default function SignOutPage() {
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-slate-500">
|
||||
<p>
|
||||
© 2024 HRIStudio. A platform for Human-Robot Interaction research.
|
||||
© {new Date().getFullYear()} HRIStudio. A platform for Human-Robot
|
||||
Interaction research.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { startTour } = useTour();
|
||||
|
||||
@@ -3,7 +3,6 @@ import "~/styles/globals.css";
|
||||
import { type Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -24,9 +23,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable}`}>
|
||||
<body>
|
||||
<SessionProvider>
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
</SessionProvider>
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Logo } from "~/components/ui/logo";
|
||||
import { auth } from "~/server/auth";
|
||||
import { auth } from "~/lib/auth";
|
||||
import {
|
||||
ArrowRight,
|
||||
Beaker,
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (session?.user) {
|
||||
redirect("/dashboard");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,10 +8,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { auth } from "~/server/auth";
|
||||
import { auth } from "~/lib/auth";
|
||||
|
||||
export default async function UnauthorizedPage() {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
|
||||
@@ -60,13 +63,6 @@ export default async function UnauthorizedPage() {
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { signOut, useSession } from "~/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -203,7 +203,8 @@ export function AppSidebar({
|
||||
: [];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut({ callbackUrl: "/" });
|
||||
await signOut();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const handleStudySelect = async (studyId: string) => {
|
||||
|
||||
@@ -167,7 +167,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
// Robot initialization mutation (for startup routine)
|
||||
const initializeRobotMutation = api.robots.initialize.useMutation({
|
||||
const initializeRobotMutation = api.robots.plugins.initialize.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Robot initialized", {
|
||||
description: "Autonomous Life disabled and robot awake.",
|
||||
@@ -188,7 +188,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
const executeSystemActionMutation =
|
||||
api.robots.executeSystemAction.useMutation();
|
||||
api.robots.plugins.executeSystemAction.useMutation();
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
// Map database step types to component step types
|
||||
@@ -579,14 +579,20 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
};
|
||||
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
console.log(`[DEBUG] handleNextStep called: targetIndex=${targetIndex}, currentStepIndex=${currentStepIndex}`);
|
||||
console.log(`[DEBUG] Steps: ${steps.map((s, i) => `${i}:${s.name}`).join(' | ')}`);
|
||||
|
||||
console.log(
|
||||
`[DEBUG] handleNextStep called: targetIndex=${targetIndex}, currentStepIndex=${currentStepIndex}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Steps: ${steps.map((s, i) => `${i}:${s.name}`).join(" | ")}`,
|
||||
);
|
||||
|
||||
// If explicit target provided (from branching choice), use it
|
||||
if (typeof targetIndex === "number") {
|
||||
// Find step by index to ensure safety
|
||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex} (${steps[targetIndex]?.name})`);
|
||||
console.log(
|
||||
`[WizardInterface] Manual jump to step ${targetIndex} (${steps[targetIndex]?.name})`,
|
||||
);
|
||||
|
||||
// Log manual jump
|
||||
logEventMutation.mutate({
|
||||
@@ -613,7 +619,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[DEBUG] Invalid targetIndex: ${targetIndex}, steps.length=${steps.length}`);
|
||||
console.warn(
|
||||
`[DEBUG] Invalid targetIndex: ${targetIndex}, steps.length=${steps.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,10 +876,14 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
if (parameters.nextStepId) {
|
||||
const nextId = String(parameters.nextStepId);
|
||||
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||
console.log(`[DEBUG] Branch choice: value=${parameters.value}, label=${parameters.label}`);
|
||||
console.log(
|
||||
`[DEBUG] Branch choice: value=${parameters.value}, label=${parameters.label}`,
|
||||
);
|
||||
console.log(`[DEBUG] Target step ID: ${nextId}`);
|
||||
console.log(`[DEBUG] Target index in steps array: ${targetIndex}`);
|
||||
console.log(`[DEBUG] Available step IDs: ${steps.map(s => s.id).join(', ')}`);
|
||||
console.log(
|
||||
`[DEBUG] Available step IDs: ${steps.map((s) => s.id).join(", ")}`,
|
||||
);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(
|
||||
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
||||
|
||||
@@ -403,8 +403,8 @@ export function WizardExecutionPanel({
|
||||
size="lg"
|
||||
onClick={
|
||||
currentStepIndex === steps.length - 1
|
||||
? onCompleteTrial
|
||||
: () => onNextStep()
|
||||
? (onCompleteTrial ?? (() => {}))
|
||||
: () => onNextStep?.()
|
||||
}
|
||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
|
||||
currentStepIndex === steps.length - 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type TrialStatus =
|
||||
|
||||
@@ -1,68 +1,15 @@
|
||||
// Client-side role utilities without database imports
|
||||
import type { Session } from "next-auth";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
|
||||
// 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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import { signOut } from "~/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
@@ -104,10 +104,8 @@ export async function handleAuthError(
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await signOut({
|
||||
callbackUrl: "/",
|
||||
redirect: true,
|
||||
});
|
||||
await signOut();
|
||||
window.location.href = "/";
|
||||
} catch (signOutError) {
|
||||
console.error("Error during sign out:", signOutError);
|
||||
// Force redirect if signOut fails
|
||||
|
||||
79
src/lib/auth.ts
Normal file
79
src/lib/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
users,
|
||||
accounts,
|
||||
sessions,
|
||||
verificationTokens,
|
||||
} from "~/server/db/schema";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const baseURL =
|
||||
process.env.NEXTAUTH_URL ||
|
||||
process.env.BETTER_AUTH_URL ||
|
||||
"http://localhost:3000";
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
user: users,
|
||||
account: accounts,
|
||||
session: sessions,
|
||||
verification: verificationTokens,
|
||||
},
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: {
|
||||
hash: async (password: string) => {
|
||||
return bcrypt.hash(password, 12);
|
||||
},
|
||||
verify: async ({
|
||||
hash,
|
||||
password,
|
||||
}: {
|
||||
hash: string;
|
||||
password: string;
|
||||
}) => {
|
||||
return bcrypt.compare(password, hash);
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 30,
|
||||
updateAge: 60 * 60 * 24,
|
||||
modelName: "session",
|
||||
fields: {
|
||||
id: "id",
|
||||
token: "token",
|
||||
userId: "userId",
|
||||
expiresAt: "expiresAt",
|
||||
ipAddress: "ipAddress",
|
||||
userAgent: "userAgent",
|
||||
},
|
||||
},
|
||||
account: {
|
||||
modelName: "account",
|
||||
fields: {
|
||||
id: "id",
|
||||
providerId: "providerId",
|
||||
accountId: "accountId",
|
||||
userId: "userId",
|
||||
accessToken: "accessToken",
|
||||
refreshToken: "refreshToken",
|
||||
expiresAt: "expiresAt",
|
||||
scope: "scope",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
error: "/auth/error",
|
||||
},
|
||||
plugins: [nextCookies()],
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
@@ -38,12 +38,15 @@ export const authRouter = createTRPCRouter({
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
try {
|
||||
// Create user
|
||||
// Create user with text ID
|
||||
const userId = `user_${crypto.randomUUID()}`;
|
||||
const newUsers = await ctx.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: userId,
|
||||
name,
|
||||
email,
|
||||
emailVerified: false,
|
||||
password: hashedPassword,
|
||||
})
|
||||
.returning({
|
||||
|
||||
@@ -428,7 +428,7 @@ export const dashboardRouter = createTRPCRouter({
|
||||
session: {
|
||||
userId: ctx.session.user.id,
|
||||
userEmail: ctx.session.user.email,
|
||||
userRole: ctx.session.user.roles?.[0]?.role ?? null,
|
||||
userRole: systemRoles[0]?.role ?? null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,8 @@ import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { auth } from "~/server/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { db } from "~/server/db";
|
||||
import { userSystemRoles } from "~/server/db/schema";
|
||||
|
||||
@@ -29,7 +30,9 @@ import { userSystemRoles } from "~/server/db/schema";
|
||||
* @see https://trpc.io/docs/server/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type DefaultSession, type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
* object and keep type safety.
|
||||
*
|
||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
*/
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
roles: Array<{
|
||||
role: "administrator" | "researcher" | "wizard" | "observer";
|
||||
grantedAt: Date;
|
||||
grantedBy: string | null;
|
||||
}>;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
|
||||
*
|
||||
* @see https://next-auth.js.org/configuration/options
|
||||
*/
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
session: {
|
||||
strategy: "jwt" as const,
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
error: "/auth/error",
|
||||
},
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
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, parsed.data.email),
|
||||
});
|
||||
|
||||
if (!user?.password) return null;
|
||||
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
parsed.data.password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isValidPassword) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt: async ({ token, user }) => {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session: async ({ session, token }) => {
|
||||
if (token.id && typeof token.id === "string") {
|
||||
// Fetch user roles from database
|
||||
const userWithRoles = await db.query.users.findFirst({
|
||||
where: eq(users.id, token.id),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
grantedByUser: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id,
|
||||
roles:
|
||||
userWithRoles?.systemRoles?.map((sr) => ({
|
||||
role: sr.role,
|
||||
grantedAt: sr.grantedAt,
|
||||
grantedBy: sr.grantedBy,
|
||||
})) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { cache } from "react";
|
||||
|
||||
import { authConfig } from "./config";
|
||||
|
||||
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
|
||||
|
||||
const auth = cache(uncachedAuth);
|
||||
|
||||
export { auth, handlers, signIn, signOut };
|
||||
@@ -1,233 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "~/server/db";
|
||||
import { users, userSystemRoles } from "~/server/db/schema";
|
||||
import { auth } from "./index";
|
||||
|
||||
// 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),
|
||||
}));
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { type AdapterAccount } from "next-auth/adapters";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
@@ -114,15 +113,12 @@ export const exportStatusEnum = pgEnum("export_status", [
|
||||
"failed",
|
||||
]);
|
||||
|
||||
// Users and Authentication
|
||||
// Users and Authentication (Better Auth compatible)
|
||||
export const users = createTable("user", {
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
emailVerified: timestamp("email_verified", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}),
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: varchar("name", { length: 255 }),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
password: varchar("password", { length: 255 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@@ -137,23 +133,20 @@ export const users = createTable("user", {
|
||||
export const accounts = createTable(
|
||||
"account",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
id: text("id").notNull().primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 255 })
|
||||
.$type<AdapterAccount["type"]>()
|
||||
.notNull(),
|
||||
provider: varchar("provider", { length: 255 }).notNull(),
|
||||
providerAccountId: varchar("provider_account_id", {
|
||||
length: 255,
|
||||
}).notNull(),
|
||||
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
||||
accountId: varchar("account_id", { length: 255 }).notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
tokenType: varchar("token_type", { length: 255 }),
|
||||
expiresAt: timestamp("expires_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}),
|
||||
scope: varchar("scope", { length: 255 }),
|
||||
idToken: text("id_token"),
|
||||
sessionState: varchar("session_state", { length: 255 }),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
@@ -162,25 +155,25 @@ export const accounts = createTable(
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.provider, table.providerAccountId],
|
||||
}),
|
||||
userIdIdx: index("account_user_id_idx").on(table.userId),
|
||||
providerAccountIdx: unique().on(table.providerId, table.accountId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const sessions = createTable(
|
||||
"session",
|
||||
{
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
sessionToken: varchar("session_token", { length: 255 }).notNull().unique(),
|
||||
userId: uuid("user_id")
|
||||
id: text("id").notNull().primaryKey(),
|
||||
token: varchar("token", { length: 255 }).notNull().unique(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expires: timestamp("expires", {
|
||||
expiresAt: timestamp("expires_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}).notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
@@ -196,18 +189,25 @@ export const sessions = createTable(
|
||||
export const verificationTokens = createTable(
|
||||
"verification_token",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
identifier: varchar("identifier", { length: 255 }).notNull(),
|
||||
token: varchar("token", { length: 255 }).notNull().unique(),
|
||||
expires: timestamp("expires", {
|
||||
value: varchar("value", { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp("expires_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({ columns: [table.identifier, table.token] }),
|
||||
identifierIdx: index("verification_token_identifier_idx").on(
|
||||
table.identifier,
|
||||
),
|
||||
valueIdx: index("verification_token_value_idx").on(table.value),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -216,14 +216,14 @@ export const userSystemRoles = createTable(
|
||||
"user_system_role",
|
||||
{
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: systemRoleEnum("role").notNull(),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
grantedBy: uuid("granted_by").references(() => users.id),
|
||||
grantedBy: text("granted_by").references(() => users.id),
|
||||
},
|
||||
(table) => ({
|
||||
userRoleUnique: unique().on(table.userId, table.role),
|
||||
@@ -263,7 +263,7 @@ export const studies = createTable("study", {
|
||||
institution: varchar("institution", { length: 255 }),
|
||||
irbProtocol: varchar("irb_protocol", { length: 100 }),
|
||||
status: studyStatusEnum("status").default("draft").notNull(),
|
||||
createdBy: uuid("created_by")
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@@ -284,7 +284,7 @@ export const studyMembers = createTable(
|
||||
studyId: uuid("study_id")
|
||||
.notNull()
|
||||
.references(() => studies.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: studyMemberRoleEnum("role").notNull(),
|
||||
@@ -292,7 +292,7 @@ export const studyMembers = createTable(
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
invitedBy: uuid("invited_by").references(() => users.id),
|
||||
invitedBy: text("invited_by").references(() => users.id),
|
||||
},
|
||||
(table) => ({
|
||||
studyUserUnique: unique().on(table.studyId, table.userId),
|
||||
@@ -380,7 +380,7 @@ export const experiments = createTable(
|
||||
robotId: uuid("robot_id").references(() => robots.id),
|
||||
status: experimentStatusEnum("status").default("draft").notNull(),
|
||||
estimatedDuration: integer("estimated_duration"), // in minutes
|
||||
createdBy: uuid("created_by")
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@@ -449,7 +449,7 @@ export const participantDocuments = createTable(
|
||||
type: varchar("type", { length: 100 }), // MIME type or custom category
|
||||
storagePath: text("storage_path").notNull(),
|
||||
fileSize: integer("file_size"),
|
||||
uploadedBy: uuid("uploaded_by").references(() => users.id),
|
||||
uploadedBy: text("uploaded_by").references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
@@ -467,7 +467,7 @@ export const trials = createTable("trial", {
|
||||
.notNull()
|
||||
.references(() => experiments.id),
|
||||
participantId: uuid("participant_id").references(() => participants.id),
|
||||
wizardId: uuid("wizard_id").references(() => users.id),
|
||||
wizardId: text("wizard_id").references(() => users.id),
|
||||
sessionNumber: integer("session_number").default(1).notNull(),
|
||||
status: trialStatusEnum("status").default("scheduled").notNull(),
|
||||
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
|
||||
@@ -562,7 +562,7 @@ export const consentForms = createTable(
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
createdBy: uuid("created_by")
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@@ -645,7 +645,7 @@ export const studyPlugins = createTable(
|
||||
installedAt: timestamp("installed_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
installedBy: uuid("installed_by")
|
||||
installedBy: text("installed_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
},
|
||||
@@ -674,7 +674,7 @@ export const pluginRepositories = createTable(
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
createdBy: uuid("created_by")
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
},
|
||||
@@ -697,7 +697,7 @@ export const trialEvents = createTable(
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
data: jsonb("data").default({}),
|
||||
createdBy: uuid("created_by").references(() => users.id), // NULL for system events
|
||||
createdBy: text("created_by").references(() => users.id), // NULL for system events
|
||||
},
|
||||
(table) => ({
|
||||
trialTimestampIdx: index("trial_events_trial_timestamp_idx").on(
|
||||
@@ -712,7 +712,7 @@ export const wizardInterventions = createTable("wizard_intervention", {
|
||||
trialId: uuid("trial_id")
|
||||
.notNull()
|
||||
.references(() => trials.id, { onDelete: "cascade" }),
|
||||
wizardId: uuid("wizard_id")
|
||||
wizardId: text("wizard_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
interventionType: varchar("intervention_type", { length: 100 }).notNull(),
|
||||
@@ -771,7 +771,7 @@ export const annotations = createTable("annotation", {
|
||||
trialId: uuid("trial_id")
|
||||
.notNull()
|
||||
.references(() => trials.id, { onDelete: "cascade" }),
|
||||
annotatorId: uuid("annotator_id")
|
||||
annotatorId: text("annotator_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
timestampStart: timestamp("timestamp_start", {
|
||||
@@ -799,7 +799,7 @@ export const activityLogs = createTable(
|
||||
studyId: uuid("study_id").references(() => studies.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: uuid("user_id").references(() => users.id),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
action: varchar("action", { length: 100 }).notNull(),
|
||||
resourceType: varchar("resource_type", { length: 50 }),
|
||||
resourceId: uuid("resource_id"),
|
||||
@@ -824,7 +824,7 @@ export const comments = createTable("comment", {
|
||||
parentId: uuid("parent_id"),
|
||||
resourceType: varchar("resource_type", { length: 50 }).notNull(), // 'experiment', 'trial', 'annotation'
|
||||
resourceId: uuid("resource_id").notNull(),
|
||||
authorId: uuid("author_id")
|
||||
authorId: text("author_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
content: text("content").notNull(),
|
||||
@@ -846,7 +846,7 @@ export const attachments = createTable("attachment", {
|
||||
filePath: text("file_path").notNull(),
|
||||
contentType: varchar("content_type", { length: 100 }),
|
||||
description: text("description"),
|
||||
uploadedBy: uuid("uploaded_by")
|
||||
uploadedBy: text("uploaded_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@@ -860,7 +860,7 @@ export const exportJobs = createTable("export_job", {
|
||||
studyId: uuid("study_id")
|
||||
.notNull()
|
||||
.references(() => studies.id, { onDelete: "cascade" }),
|
||||
requestedBy: uuid("requested_by")
|
||||
requestedBy: text("requested_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
exportType: varchar("export_type", { length: 50 }).notNull(), // 'full', 'trials', 'analysis', 'media'
|
||||
@@ -883,7 +883,7 @@ export const sharedResources = createTable("shared_resource", {
|
||||
.references(() => studies.id, { onDelete: "cascade" }),
|
||||
resourceType: varchar("resource_type", { length: 50 }).notNull(),
|
||||
resourceId: uuid("resource_id").notNull(),
|
||||
sharedBy: uuid("shared_by")
|
||||
sharedBy: text("shared_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
shareToken: varchar("share_token", { length: 255 }).unique(),
|
||||
@@ -901,7 +901,7 @@ export const systemSettings = createTable("system_setting", {
|
||||
key: varchar("key", { length: 100 }).notNull().unique(),
|
||||
value: jsonb("value").notNull(),
|
||||
description: text("description"),
|
||||
updatedBy: uuid("updated_by").references(() => users.id),
|
||||
updatedBy: text("updated_by").references(() => users.id),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
@@ -911,7 +911,7 @@ export const auditLogs = createTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
action: varchar("action", { length: 100 }).notNull(),
|
||||
resourceType: varchar("resource_type", { length: 50 }),
|
||||
resourceId: uuid("resource_id"),
|
||||
|
||||
27
src/trpc/query-client.js
Normal file
27
src/trpc/query-client.js
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createQueryClient = void 0;
|
||||
var react_query_1 = require("@tanstack/react-query");
|
||||
var superjson_1 = require("superjson");
|
||||
var createQueryClient = function () {
|
||||
return new react_query_1.QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 30 * 1000,
|
||||
},
|
||||
dehydrate: {
|
||||
serializeData: superjson_1.default.serialize,
|
||||
shouldDehydrateQuery: function (query) {
|
||||
return (0, react_query_1.defaultShouldDehydrateQuery)(query) ||
|
||||
query.state.status === "pending";
|
||||
},
|
||||
},
|
||||
hydrate: {
|
||||
deserializeData: superjson_1.default.deserialize,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
exports.createQueryClient = createQueryClient;
|
||||
59
src/trpc/react.js
vendored
Normal file
59
src/trpc/react.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.api = void 0;
|
||||
exports.TRPCReactProvider = TRPCReactProvider;
|
||||
var react_query_1 = require("@tanstack/react-query");
|
||||
var client_1 = require("@trpc/client");
|
||||
var react_query_2 = require("@trpc/react-query");
|
||||
var react_1 = require("react");
|
||||
var superjson_1 = require("superjson");
|
||||
var query_client_1 = require("./query-client");
|
||||
var clientQueryClientSingleton = undefined;
|
||||
var getQueryClient = function () {
|
||||
if (typeof window === "undefined") {
|
||||
// Server: always make a new query client
|
||||
return (0, query_client_1.createQueryClient)();
|
||||
}
|
||||
// Browser: use singleton pattern to keep the same query client
|
||||
clientQueryClientSingleton !== null && clientQueryClientSingleton !== void 0 ? clientQueryClientSingleton : (clientQueryClientSingleton = (0, query_client_1.createQueryClient)());
|
||||
return clientQueryClientSingleton;
|
||||
};
|
||||
exports.api = (0, react_query_2.createTRPCReact)();
|
||||
function TRPCReactProvider(props) {
|
||||
var queryClient = getQueryClient();
|
||||
var trpcClient = (0, react_1.useState)(function () {
|
||||
return exports.api.createClient({
|
||||
links: [
|
||||
(0, client_1.loggerLink)({
|
||||
enabled: function (op) {
|
||||
return process.env.NODE_ENV === "development" ||
|
||||
(op.direction === "down" && op.result instanceof Error);
|
||||
},
|
||||
}),
|
||||
(0, client_1.httpBatchStreamLink)({
|
||||
transformer: superjson_1.default,
|
||||
url: getBaseUrl() + "/api/trpc",
|
||||
headers: function () {
|
||||
var headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
})[0];
|
||||
return (<react_query_1.QueryClientProvider client={queryClient}>
|
||||
<exports.api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
{props.children}
|
||||
</exports.api.Provider>
|
||||
</react_query_1.QueryClientProvider>);
|
||||
}
|
||||
function getBaseUrl() {
|
||||
var _a;
|
||||
if (typeof window !== "undefined")
|
||||
return window.location.origin;
|
||||
if (process.env.VERCEL_URL)
|
||||
return "https://".concat(process.env.VERCEL_URL);
|
||||
return "http://localhost:".concat((_a = process.env.PORT) !== null && _a !== void 0 ? _a : 3000);
|
||||
}
|
||||
Reference in New Issue
Block a user