diff --git a/src/api/trpc/routers/user.ts b/src/api/trpc/routers/user.ts
index a1b2c3d..e4f5g6h 100644
--- a/src/api/trpc/routers/user.ts
+++ b/src/api/trpc/routers/user.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { router, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
+import { Prisma } from "@prisma/client";
const updateUserSchema = z.object({
id: z.string().uuid(),
@@ -15,7 +16,12 @@ export const userRouter = router({
getById: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
- const user = await ctx.db.user.findUnique({ where: { id: input.id } });
+ const user = await ctx.db.user.findUnique({
+ where: { id: input.id },
+ include: {
+ profile: true,
+ subscription: true,
+ },
+ });
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
@@ -31,13 +37,22 @@ export const userRouter = router({
updateRole: protectedProcedure
.input(z.object({ userId: z.string().uuid(), role: z.enum(["user", "admin", "moderator"]) }))
.mutation(async ({ ctx, input }) => {
- if (ctx.session.user.role !== "admin") {
+ const currentUser = ctx.session.user;
+ if (currentUser.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
- return ctx.db.user.update({
+ if (input.userId === currentUser.id) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot change your own role",
+ });
+ }
+ const updated = await ctx.db.user.update({
where: { id: input.userId },
- data: { role: input.role },
+ data: { role: input.role, updatedAt: new Date() },
});
+ await ctx.audit.log("user.role.changed", { userId: input.userId, newRole: input.role, changedBy: currentUser.id });
+ return updated;
}),
});
diff --git a/src/components/UserList.tsx b/src/components/UserList.tsx
index b2c3d4e..f5g6h7i 100644
--- a/src/components/UserList.tsx
+++ b/src/components/UserList.tsx
@@ -1,7 +1,8 @@
import { useState } from "react";
import { trpc } from "@/utils/trpc";
-import { Spinner } from "./Spinner";
+import { Skeleton } from "./Skeleton";
import { UserCard } from "./UserCard";
+import { EmptyState } from "./EmptyState";
interface UserListProps {
roleFilter?: string;
@@ -12,20 +13,28 @@ export function UserList({ roleFilter, searchQuery }: UserListProps) {
const [page, setPage] = useState(1);
const { data, isLoading, error } = trpc.user.list.useQuery({
page,
- limit: 10,
+ limit: 20,
role: roleFilter,
+ search: searchQuery,
});
- if (isLoading) return <Spinner />;
- if (error) return <div>Error: {error.message}</div>;
+ if (isLoading) {
+ return (
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {Array.from({ length: 6 }).map((_, i) => (
+ <Skeleton key={i} className="h-48 w-full rounded-lg" />
+ ))}
+ </div>
+ );
+ }
+
+ if (error) {
+ return <div className="rounded-lg bg-red-50 p-4 text-red-700">{error.message}</div>;
+ }
+
+ if (!data?.users.length) {
+ return <EmptyState title="No users found" description="Try adjusting your search or filter criteria." />;
+ }
return (
- <div>
- <div className="space-y-4">
- {data?.users.map((user) => (
- <UserCard key={user.id} user={user} />
- ))}
- </div>
+ <div className="space-y-6">
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {data.users.map((user) => (
+ <UserCard key={user.id} user={user} />
+ ))}
+ </div>
{data?.totalPages > 1 && (
- <div className="mt-4 flex justify-center gap-2">
- <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
+ <div className="flex items-center justify-center gap-2">
+ <button
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ className="rounded-md bg-gray-100 px-3 py-2 text-sm font-medium hover:bg-gray-200 disabled:opacity-50"
+ >
Previous
</button>
- <span>Page {page} of {data.totalPages}</span>
- <button onClick={() => setPage(p => Math.min(data.totalPages, p + 1))} disabled={page === data.totalPages}>
+ <span className="text-sm text-gray-600">
+ Page {page} of {data.totalPages}
+ </span>
+ <button
+ onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
+ disabled={page === data.totalPages}
+ className="rounded-md bg-gray-100 px-3 py-2 text-sm font-medium hover:bg-gray-200 disabled:opacity-50"
+ >
Next
</button>
</div>
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index c3d4e5f..g6h7i8j 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,6 +1,7 @@
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
+import Resend from "next-auth/providers/resend";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./database";
@@ -8,7 +9,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
- providers: [GitHub, Google],
+ providers: [GitHub, Google, Resend({ from: "noreply@my-saas-app.com" })],
callbacks: {
session({ session, user }) {
session.user.id = user.id;
@@ -18,6 +19,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return session;
},
+ async signIn({ user, account }) {
+ if (account?.provider === "resend") {
+ const existingUser = await prisma.user.findUnique({ where: { email: user.email! } });
+ if (existingUser?.emailVerified) {
+ return true;
+ }
+ }
+ return true;
+ },
},
pages: {
signIn: "/auth/signin",
diff --git a/src/lib/database.ts b/src/lib/database.ts
index d4e5f6g..h7i8j9k 100644
--- a/src/lib/database.ts
+++ b/src/lib/database.ts
@@ -1,10 +1,18 @@
-import { PrismaClient } from "@prisma/client";
+import { PrismaClient, Prisma } from "@prisma/client";
-const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined };
+const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
-export const prisma = globalForPrisma.prisma ?? new PrismaClient();
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
+ });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+
+export async function withTransaction<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
+ return prisma.$transaction(fn, { maxWait: 5000, timeout: 10000 });
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index e5f6g7h..i8j9k0l 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,13 +1,29 @@
import { auth } from "@/lib/auth";
+import { NextResponse } from "next/server";
-export default auth;
+const publicPaths = ["/", "/auth/signin", "/auth/signup", "/pricing", "/docs"];
+const apiPublicPaths = ["/api/webhooks/stripe", "/api/health"];
-export const config = {
- matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
-};
+export default auth((req) => {
+ const { pathname } = req.nextUrl;
+
+ if (publicPaths.some((p) => pathname === p) || apiPublicPaths.some((p) => pathname.startsWith(p))) {
+ return NextResponse.next();
+ }
+
+ if (!req.auth && pathname.startsWith("/dashboard")) {
+ const signInUrl = new URL("/auth/signin", req.url);
+ signInUrl.searchParams.set("callbackUrl", pathname);
+ return NextResponse.redirect(signInUrl);
+ }
+
+ return NextResponse.next();
+});
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|ico)$).*)"],
+};
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f6g7h8i..j9k0l1m 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -22,6 +22,8 @@ model User {
emailVerified DateTime?
image String?
role String @default("user")
+ lastLoginAt DateTime?
+ onboardedAt DateTime?
accounts Account[]
sessions Session[]
projects Project[]