Add authentication

This commit is contained in:
2025-07-18 19:56:07 -04:00
parent 3b9c0cc31b
commit 1121e5c6ff
25 changed files with 3047 additions and 109 deletions

View File

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

View File

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

View File

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