mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
Add authentication
This commit is contained in:
@@ -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