From 2ed86dafe9ba4f80e22b30010dca8948c0195c57 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Tue, 31 Mar 2026 10:24:35 -0700 Subject: [PATCH] feat: add user management panel for superusers --- src/app/admin/users/[id]/edit/page.tsx | 81 ++++++++ .../admin/users/components/CreateUserForm.tsx | 173 ++++++++++++++++ .../admin/users/components/EditUserForm.tsx | 158 +++++++++++++++ src/app/admin/users/create/page.tsx | 75 +++++++ src/app/admin/users/page.tsx | 173 ++++++++++++++++ src/app/api/admin/users/[id]/route.ts | 188 ++++++++++++++++++ src/app/api/admin/users/route.ts | 127 ++++++++++++ src/components/Navigation.tsx | 6 + 8 files changed, 981 insertions(+) create mode 100644 src/app/admin/users/[id]/edit/page.tsx create mode 100644 src/app/admin/users/components/CreateUserForm.tsx create mode 100644 src/app/admin/users/components/EditUserForm.tsx create mode 100644 src/app/admin/users/create/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/admin/users/[id]/route.ts create mode 100644 src/app/api/admin/users/route.ts diff --git a/src/app/admin/users/[id]/edit/page.tsx b/src/app/admin/users/[id]/edit/page.tsx new file mode 100644 index 0000000..85b425e --- /dev/null +++ b/src/app/admin/users/[id]/edit/page.tsx @@ -0,0 +1,81 @@ +import { prisma } from "@/lib/prisma" +import Navigation from "@/components/Navigation" +import Link from "next/link" +import { redirect } from "next/navigation" +import { hasRole } from "@/lib/permissions" +import EditUserForm from "@/app/admin/users/components/EditUserForm" + +export const dynamic = "force-dynamic"; + +export default async function EditUserPage({ + params, +}: { + params: { id: string }; +}) { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + redirect("/auth/login"); + } + + const { id } = params; + + // Get user info + const user = await prisma.user.findUnique({ + where: { id }, + include: { + player: true, + }, + }); + + if (!user) { + redirect("/admin/users"); + } + + // Get all players without associated users (excluding the current user's player) + const availablePlayers = await prisma.player.findMany({ + where: { + OR: [ + { user: null }, + { id: user.playerId || -1 }, // Include the currently associated player if any + ], + }, + orderBy: { name: "asc" }, + }); + + return ( +
+ + +
+
+ {/* Page Header */} +
+
+
+

Edit User

+

+ Update user account and player association. +

+
+ + ← Back to Users + +
+
+ + {/* Edit User Form */} +
+ +
+
+
+
+ ); +} diff --git a/src/app/admin/users/components/CreateUserForm.tsx b/src/app/admin/users/components/CreateUserForm.tsx new file mode 100644 index 0000000..88bc876 --- /dev/null +++ b/src/app/admin/users/components/CreateUserForm.tsx @@ -0,0 +1,173 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import type { Player } from "@prisma/client" + +interface CreateUserFormProps { + selectedPlayer: Player | null; + availablePlayers: Player[]; +} + +export default function CreateUserForm({ selectedPlayer, availablePlayers }: CreateUserFormProps) { + const router = useRouter() + const [formData, setFormData] = useState({ + email: "", + password: "", + name: "", + role: "player", + playerId: selectedPlayer?.id?.toString() || "", + }) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setSuccess("") + setLoading(true) + + try { + const response = await fetch("/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to create user") + } + + setSuccess("User created successfully!") + setTimeout(() => { + router.push("/admin/users") + }, 1500) + } catch (err) { + if (err instanceof Error) { + setError(err.message) + } else { + setError("An unknown error occurred") + } + } finally { + setLoading(false) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+
+ + setFormData({ ...formData, email: e.target.value })} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/src/app/admin/users/components/EditUserForm.tsx b/src/app/admin/users/components/EditUserForm.tsx new file mode 100644 index 0000000..b8a2c1a --- /dev/null +++ b/src/app/admin/users/components/EditUserForm.tsx @@ -0,0 +1,158 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import type { User, Player } from "@prisma/client" + +interface EditUserFormProps { + user: User & { player?: Player | null }; + availablePlayers: Player[]; +} + +export default function EditUserForm({ user, availablePlayers }: EditUserFormProps) { + const router = useRouter() + const [formData, setFormData] = useState({ + email: user.email, + name: user.name || "", + role: user.role, + playerId: user.playerId?.toString() || "", + }) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setSuccess("") + setLoading(true) + + try { + const response = await fetch(`/api/admin/users/${user.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to update user") + } + + setSuccess("User updated successfully!") + setTimeout(() => { + router.push("/admin/users") + }, 1500) + } catch (err) { + if (err instanceof Error) { + setError(err.message) + } else { + setError("An unknown error occurred") + } + } finally { + setLoading(false) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+
+ + setFormData({ ...formData, email: e.target.value })} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/src/app/admin/users/create/page.tsx b/src/app/admin/users/create/page.tsx new file mode 100644 index 0000000..de1d8b3 --- /dev/null +++ b/src/app/admin/users/create/page.tsx @@ -0,0 +1,75 @@ +import { prisma } from "@/lib/prisma" +import Navigation from "@/components/Navigation" +import Link from "next/link" +import { redirect } from "next/navigation" +import { hasRole } from "@/lib/permissions" +import CreateUserForm from "../components/CreateUserForm" + +export const dynamic = "force-dynamic"; + +export default async function CreateUserPage({ + searchParams, +}: { + searchParams: { playerId?: string }; +}) { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + redirect("/auth/login"); + } + + const playerId = searchParams.playerId ? parseInt(searchParams.playerId) : null; + + // Get player info if provided + let selectedPlayer = null; + if (playerId) { + selectedPlayer = await prisma.player.findUnique({ + where: { id: playerId }, + }); + } + + // Get all players without associated users + const availablePlayers = await prisma.player.findMany({ + where: { + user: null, + ...(playerId ? { id: { not: playerId } } : {}), + }, + orderBy: { name: "asc" }, + }); + + return ( +
+ + +
+
+ {/* Page Header */} +
+
+
+

Create User

+

+ Create a new user account and optionally associate it with a player. +

+
+ + ← Back to Users + +
+
+ + {/* Create User Form */} +
+ +
+
+
+
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..e363415 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,173 @@ +import { prisma } from "@/lib/prisma" +import Navigation from "@/components/Navigation" +import Link from "next/link" +import { redirect } from "next/navigation" +import { getSession } from "@/lib/auth-simple" +import { hasRole } from "@/lib/permissions" + +export const dynamic = "force-dynamic"; + +export default async function AdminUsersPage() { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + redirect("/auth/login"); + } + + const session = await getSession(); + const userId = session?.user?.id || session?.user?.userId; + + // Fetch all users with their associated players + const users = await prisma.user.findMany({ + orderBy: { createdAt: "desc" }, + include: { + player: true, + }, + }); + + // Fetch all players without associated users + const playersWithoutUsers = await prisma.player.findMany({ + where: { + user: null, + }, + orderBy: { name: "asc" }, + }); + + return ( +
+ + +
+
+ {/* Page Header */} +
+
+
+

User Management

+

+ Create users and associate them with players. +

+
+ + + Create User + +
+
+ + {/* Users List */} +
+
+

All Users

+
+
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
+ Email + + Name + + Role + + Associated Player + + Created + + Actions +
+ {user.email} + + {user.name || "-"} + + + {user.role} + + + {user.player ? ( + + {user.player.name} + + ) : ( + Not assigned + )} + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + Edit + +
+
+
+
+ + {/* Players without users */} + {playersWithoutUsers.length > 0 && ( +
+

+ Players without associated users +

+

+ The following players exist but don't have an associated user account. +

+
    + {playersWithoutUsers.slice(0, 5).map((player) => ( +
  • + {player.name} + + Create user for this player + +
  • + ))} + {playersWithoutUsers.length > 5 && ( +
  • + ...and {playersWithoutUsers.length - 5} more +
  • + )} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..3c50d23 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,188 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { hasRole } from "@/lib/permissions"; + +export async function PUT( + request: Request, + { params }: { params: { id: string } } +) { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Unauthorized" }, + { status: 403 } + ); + } + + try { + const { id } = params; + const body = await request.json(); + const { email, name, role, playerId } = body; + + // Check if user exists + const existingUser = await prisma.user.findUnique({ + where: { id }, + }); + + if (!existingUser) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Update the user + const user = await prisma.user.update({ + where: { id }, + data: { + email: email || existingUser.email, + name: name !== undefined ? name : existingUser.name, + role: role || existingUser.role, + playerId: playerId ? parseInt(playerId) : null, + }, + }); + + // Update the player's user relation if needed + if (playerId !== undefined) { + // First, remove user from any existing player + if (existingUser.playerId && existingUser.playerId !== parseInt(playerId)) { + await prisma.player.update({ + where: { id: existingUser.playerId }, + data: { user: { disconnect: true } }, + }); + } + + // Then, connect user to the new player + if (playerId) { + await prisma.player.update({ + where: { id: parseInt(playerId) }, + data: { user: { connect: { id: user.id } } }, + }); + } + } + + return NextResponse.json({ + success: true, + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + playerId: user.playerId, + }, + }); + } catch (error) { + console.error("Error updating user:", error); + return NextResponse.json( + { error: "Failed to update user" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + // Check permissions - only site_admin can delete users + const permission = await hasRole("site_admin"); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Unauthorized" }, + { status: 403 } + ); + } + + try { + const { id } = params; + + // Check if user exists + const existingUser = await prisma.user.findUnique({ + where: { id }, + }); + + if (!existingUser) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Cannot delete the last site_admin + const siteAdminCount = await prisma.user.count({ + where: { role: "site_admin" }, + }); + + if (existingUser.role === "site_admin" && siteAdminCount <= 1) { + return NextResponse.json( + { error: "Cannot delete the last site_admin user" }, + { status: 400 } + ); + } + + // Remove user from player if associated + if (existingUser.playerId) { + await prisma.player.update({ + where: { id: existingUser.playerId }, + data: { user: { disconnect: true } }, + }); + } + + // Delete the user + await prisma.user.delete({ + where: { id }, + }); + + return NextResponse.json({ + success: true, + message: "User deleted successfully", + }); + } catch (error) { + console.error("Error deleting user:", error); + return NextResponse.json( + { error: "Failed to delete user" }, + { status: 500 } + ); + } +} + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Unauthorized" }, + { status: 403 } + ); + } + + try { + const { id } = params; + + const user = await prisma.user.findUnique({ + where: { id }, + include: { + player: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ user }); + } catch (error) { + console.error("Error fetching user:", error); + return NextResponse.json( + { error: "Failed to fetch user" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..32eb953 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { hasRole } from "@/lib/permissions"; +import { auth } from "@/lib/auth"; + +export async function POST(request: Request) { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Unauthorized" }, + { status: 403 } + ); + } + + try { + const body = await request.json(); + const { email, password, name, role, playerId } = body; + + // Validate required fields + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "A user with this email already exists" }, + { status: 400 } + ); + } + + // Use Better Auth to create the user + const signUpResult = await auth.api.signUpEmail({ + body: { + email, + password, + name: name || undefined, + }, + }); + + const user = signUpResult.user; + + // Update the user's role in the database + await prisma.user.update({ + where: { id: user.id }, + data: { + role: role || "player", + playerId: playerId ? parseInt(playerId) : null, + emailVerified: true, // Admin-created users are auto-verified + }, + }); + + // If a player was associated, update the player's user relation + if (playerId) { + await prisma.player.update({ + where: { id: parseInt(playerId) }, + data: { user: { connect: { id: user.id } } }, + }); + } + + return NextResponse.json({ + success: true, + user: { + id: user.id, + email: user.email, + name: user.name, + role: role || "player", + playerId: playerId ? parseInt(playerId) : null, + }, + }); + } catch (error: any) { + console.error("Error creating user:", error); + + // Handle Better Auth specific errors + if (error.message && error.message.includes("already exists")) { + return NextResponse.json( + { error: "A user with this email already exists" }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "Failed to create user" }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + // Check permissions + const permission = await hasRole("club_admin"); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Unauthorized" }, + { status: 403 } + ); + } + + try { + const { searchParams } = new URL(request.url); + const playerId = searchParams.get("playerId"); + + const users = await prisma.user.findMany({ + where: playerId ? { playerId: parseInt(playerId) } : {}, + orderBy: { createdAt: "desc" }, + include: { + player: true, + }, + }); + + return NextResponse.json({ users }); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json( + { error: "Failed to fetch users" }, + { status: 500 } + ); + } +} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 3bf6d59..dbe4fdf 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -62,6 +62,12 @@ export default function Navigation() { > Players + + Users +