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:
Sean O'Connor
2026-03-21 23:03:55 -04:00
parent 4bed537943
commit 20d6d3de1a
37 changed files with 460 additions and 1182 deletions

View File

@@ -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({

View File

@@ -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,
},
};
}),

View File

@@ -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,

View File

@@ -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;
},
},
};

View File

@@ -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 };

View File

@@ -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),
}));
}

View File

@@ -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"),