fix: complete user admin actions - form-based inline actions and API endpoint for bulk operations
This commit is contained in:
1 parent
c3de7cb576
commit
2efd03de0f
2 files changed
+281
-52
No files matched your search
+114
-52
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user