feat: add user management panel for superusers

This commit is contained in:
2026-03-31 10:24:35 -07:00
parent 0f2228b9eb
commit 2ed86dafe9
8 changed files with 981 additions and 0 deletions
+81
View File
@@ -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 (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Page Header */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Edit User</h1>
<p className="text-gray-500 mt-1">
Update user account and player association.
</p>
</div>
<Link
href="/admin/users"
className="text-gray-600 hover:text-gray-900"
>
Back to Users
</Link>
</div>
</div>
{/* Edit User Form */}
<div className="bg-white shadow rounded-lg p-6">
<EditUserForm
user={user}
availablePlayers={availablePlayers}
/>
</div>
</div>
</main>
</div>
);
}
@@ -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 (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
{success}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password <span className="text-red-500">*</span>
</label>
<input
type="password"
id="password"
required
value={formData.password}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
Role <span className="text-red-500">*</span>
</label>
<select
id="role"
required
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: 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"
>
<option value="player">Player</option>
<option value="tournament_admin">Tournament Admin</option>
<option value="club_admin">Club Admin</option>
<option value="site_admin">Site Admin</option>
</select>
</div>
<div className="md:col-span-2">
<label htmlFor="playerId" className="block text-sm font-medium text-gray-700">
Associate with Player
</label>
<select
id="playerId"
value={formData.playerId}
onChange={(e) => setFormData({ ...formData, playerId: 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"
>
<option value="">-- No player association --</option>
{selectedPlayer && (
<option value={selectedPlayer.id}>{selectedPlayer.name} (selected)</option>
)}
{availablePlayers.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end space-x-3">
<Link
href="/admin/users"
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50"
>
{loading ? "Creating..." : "Create User"}
</button>
</div>
</form>
)
}
@@ -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 (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
{success}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
Role <span className="text-red-500">*</span>
</label>
<select
id="role"
required
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: 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"
>
<option value="player">Player</option>
<option value="tournament_admin">Tournament Admin</option>
<option value="club_admin">Club Admin</option>
<option value="site_admin">Site Admin</option>
</select>
</div>
<div className="md:col-span-2">
<label htmlFor="playerId" className="block text-sm font-medium text-gray-700">
Associate with Player
</label>
<select
id="playerId"
value={formData.playerId}
onChange={(e) => setFormData({ ...formData, playerId: 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"
>
<option value="">-- No player association --</option>
{user.player && (
<option value={user.player.id}>{user.player.name} (current)</option>
)}
{availablePlayers.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end space-x-3">
<Link
href="/admin/users"
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50"
>
{loading ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
)
}
+75
View File
@@ -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 (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Page Header */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Create User</h1>
<p className="text-gray-500 mt-1">
Create a new user account and optionally associate it with a player.
</p>
</div>
<Link
href="/admin/users"
className="text-gray-600 hover:text-gray-900"
>
Back to Users
</Link>
</div>
</div>
{/* Create User Form */}
<div className="bg-white shadow rounded-lg p-6">
<CreateUserForm
selectedPlayer={selectedPlayer}
availablePlayers={availablePlayers}
/>
</div>
</div>
</main>
</div>
);
}
+173
View File
@@ -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 (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Page Header */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-500 mt-1">
Create users and associate them with players.
</p>
</div>
<Link
href="/admin/users/create"
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors"
>
+ Create User
</Link>
</div>
</div>
{/* Users List */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">All Users</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Associated Player
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.name || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
user.role === 'site_admin' ? 'bg-purple-100 text-purple-800' :
user.role === 'club_admin' ? 'bg-blue-100 text-blue-800' :
user.role === 'tournament_admin' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.player ? (
<Link
href={`/players/${user.player.id}/profile`}
className="text-green-600 hover:text-green-900"
>
{user.player.name}
</Link>
) : (
<span className="text-gray-400">Not assigned</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex space-x-2">
<Link
href={`/admin/users/${user.id}/edit`}
className="text-blue-600 hover:text-blue-900"
>
Edit
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Players without users */}
{playersWithoutUsers.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-6">
<h3 className="text-sm font-medium text-yellow-800 mb-2">
Players without associated users
</h3>
<p className="text-sm text-yellow-700 mb-3">
The following players exist but don't have an associated user account.
</p>
<ul className="text-sm text-yellow-700">
{playersWithoutUsers.slice(0, 5).map((player) => (
<li key={player.id} className="flex items-center justify-between">
<span>{player.name}</span>
<Link
href={`/admin/users/create?playerId=${player.id}`}
className="text-yellow-800 hover:text-yellow-900 underline"
>
Create user for this player
</Link>
</li>
))}
{playersWithoutUsers.length > 5 && (
<li className="text-yellow-600">
...and {playersWithoutUsers.length - 5} more
</li>
)}
</ul>
</div>
)}
</div>
</main>
</div>
);
}
+188
View File
@@ -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 }
);
}
}
+127
View File
@@ -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 }
);
}
}
+6
View File
@@ -62,6 +62,12 @@ export default function Navigation() {
>
Players
</Link>
<Link
href="/admin/users"
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Users
</Link>
<Link
href="/admin/matches/upload"
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"