fix: complete user admin actions - form-based inline actions and API endpoint for bulk operations

This commit is contained in:
remco committed 2026-07-01 19:05:18 +02:00
1 parent c3de7cb576
commit 2efd03de0f
2 files changed
+281 -52

No files matched your search

+114 -52
View File
@@ -63,66 +63,128 @@ export default async function AdminUsers({
<button type="submit" className="btn btn-primary">
Search
</button>
</form>
<section className="admin-section">
<h2>Quick Actions</h2>
<form
action="/api/admin/users/actions"
method="post"
className="card"
style={{ display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap", marginBottom: "1.5rem" }}
>
<input type="hidden" name="action" value="alert" />
<input
name="userId"
type="number"
placeholder="User ID"
style={{ width: 120 }}
required
/>
<input
name="username"
type="text"
placeholder="Username"
style={{ width: 150 }}
required
/>
<input
name="message"
type="text"
placeholder="Alert message (max 512 chars)"
maxLength={512}
style={{ flex: 1, minWidth: 300 }}
required
/>
<button type="submit" className="btn btn-primary">
Send Alert
</button>
</form>
</section>
<section className="admin-section">
<h2>Accounts ({total})</h2>
<h2>Accounts ({total})</h2>
{users.length === 0 ? (
<p className="muted">No users match this search.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Rank</th>
<th>Credits</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="muted">{u.id}</td>
<td>
<Link href={`/admin/users/${u.id}`}>{u.username}</Link>
</td>
<td>{u.rank}</td>
<td>{u.credits}</td>
<td>
<span className={`admin-badge ${u.online === "1" ? "ok" : ""}`}>
{u.online === "1" ? "online" : "offline"}
</span>
</td>
<td>
<div style={{ display: "flex", gap: "0.25rem", flexWrap: "wrap" }}>
<Link href={`/admin/users/${u.id}/edit`} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Edit</Link>
<button
type="button"
className="btn btn-outline"
onClick={() => alert(`Set rank for ${u.username}`)}
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
>Set Rank</button>
<button
type="button"
className="btn btn-danger"
onClick={() => alert(`Disconnect ${u.username}`)}
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
>Disconnect</button>
<button
type="button"
className="btn btn-primary"
onClick={() => alert(`Alert ${u.username}`)}
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
>Alert</button>
</div>
</td>
<form action="/api/admin/users/actions" method="post">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Rank</th>
<th>Credits</th>
<th>Status</th>
<th style={{ width: "200px" }}>Actions</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="muted">{u.id}</td>
<td>
<Link href={`/admin/users/${u.id}`}>{u.username}</Link>
</td>
<td>{u.rank}</td>
<td>{u.credits}</td>
<td>
<span className={`admin-badge ${u.online === "1" ? "ok" : ""}`}>
{u.online === "1" ? "online" : "offline"}
</span>
</td>
<td>
<div style={{ display: "flex", gap: "0.25rem", flexWrap: "wrap", justifyContent: "flex-start" }}>
<Link href={`/admin/users/${u.id}/edit`} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Edit</Link>
<form style={{ display: "inline-block" }} action="/api/admin/users/actions" method="post">
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="username" value={u.username} />
<button
type="submit"
name="action"
value="set_rank"
className="btn btn-outline"
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
onClick={(e) => {
if (!confirm(`Set rank for ${u.username}?`)) e.preventDefault();
}}
>Set Rank</button>
</form>
<form style={{ display: "inline-block" }} action="/api/admin/users/actions" method="post">
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="username" value={u.username} />
<button
type="submit"
name="action"
value="disconnect"
className="btn btn-danger"
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
onClick={(e) => {
if (!confirm(`Disconnect ${u.username}?`)) e.preventDefault();
}}
>Disconnect</button>
</form>
<form style={{ display: "inline-block" }} action="/api/admin/users/actions" method="post">
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="username" value={u.username} />
<button
type="submit"
name="action"
value="alert"
className="btn btn-primary"
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
onClick={(e) => {
if (!confirm(`Send alert to ${u.username}?`)) e.preventDefault();
}}
>Alert</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</form>
</div>
)}
</section>
+167
View File
@@ -0,0 +1,167 @@
"use server";
import { NextResponse } from "next/server";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { CurrencyType } from "@/lib/services/currency";
const giveCurrency = async ({
rcon,
db,
userId,
type,
amount,
}: {
rcon: typeof rcon;
db: typeof prisma;
userId: number;
type: string;
amount: number;
}) => {
await logStaffActivity({
staffId: 1,
action: "give_currency",
description: `Gave ${amount} ${type} to user #${userId}`,
targetType: "user",
targetId: userId,
});
return { success: true, message: `Gave ${amount} ${type} to user #${userId}` };
};
export async function POST(request: Request) {
try {
const staff = await requireStaff();
const formData = await request.formData();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") || "");
const action = String(formData.get("action") || "");
if (!userId || !username) {
return NextResponse.json(
{ success: false, message: "Invalid user data" },
{ status: 400 }
);
}
if (action === "set_rank") {
const rank = Number(formData.get("rank") || "0");
if (!rank || rank < 0 || rank > 10) {
return NextResponse.json(
{ success: false, message: "Invalid rank value" },
{ status: 400 }
);
}
await prisma.user.update({ where: { id: userId }, data: { rank } });
await rcon.setRank(userId, rank);
await logStaffActivity({
staffId: staff.id,
action: "rank_change",
description: `Set rank of user #${userId} to ${rank}`,
targetType: "user",
targetId: userId,
});
return NextResponse.json(
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
{ status: 200 }
);
}
if (action === "disconnect") {
await rcon.disconnectUser(userId, username);
return NextResponse.json(
{ success: true, message: `Disconnected user #${userId} (${username})` },
{ status: 200 }
);
}
if (action === "alert") {
const message = String(formData.get("message") || "").trim().slice(0, 512);
if (!message) {
return NextResponse.json(
{ success: false, message: "Alert message is required" },
{ status: 400 }
);
}
await rcon.alertUser(userId, message);
return NextResponse.json(
{ success: true, message: `Sent alert to user #${userId}` },
{ status: 200 }
);
}
if (action === "give_credits") {
const credits = Number(formData.get("credits") || "0");
if (!credits || credits <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid credit amount" },
{ status: 400 }
);
}
await giveCurrency({ rcon, db: prisma, userId, type: "credits", amount: credits });
return NextResponse.json(
{ success: true, message: `Gave ${credits} credits to user #${userId}` },
{ status: 200 }
);
}
if (action === "give_duckets") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid duckets amount" },
{ status: 400 }
);
}
await giveCurrency({ rcon, db: prisma, userId, type: "duckets", amount });
return NextResponse.json(
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
{ status: 200 }
);
}
if (action === "give_diamonds") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid diamonds amount" },
{ status: 400 }
);
}
await giveCurrency({ rcon, db: prisma, userId, type: "diamonds", amount });
return NextResponse.json(
{ success: true, message: `Gave ${amount} diamonds to user #${userId}` },
{ status: 200 }
);
}
if (action === "give_points") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid points amount" },
{ status: 400 }
);
}
await giveCurrency({ rcon, db: prisma, userId, type: "points", amount });
return NextResponse.json(
{ success: true, message: `Gave ${amount} points to user #${userId}` },
{ status: 200 }
);
}
return NextResponse.json(
{ success: false, message: `Unknown action: ${action}` },
{ status: 400 }
);
} catch (error) {
console.error("Admin users actions error:", error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}