feat: Implement admin account deletion with confirmation and update documentation

This commit is contained in:
Alexa
2026-06-09 00:16:02 +03:00
parent 5b13e24880
commit 2b87f3d7c5
6 changed files with 415 additions and 17 deletions
@@ -203,6 +203,56 @@ public class AdminController(
return Ok(new { player.Id, player.IsAdmin });
}
public sealed record DeletePlayerRequest(string ConfirmUsername, string ConfirmPhrase, string? Reason);
/// <summary>Hard-remove a non-admin player account. The destructive
/// endpoint requires two exact confirmations: the current username and
/// the phrase <c>DELETE {id}</c>. Personal rows are deleted, authored
/// durable content is reassigned to the system account, and live sessions
/// are kicked so stale bearer tokens stop being useful immediately.</summary>
[HttpDelete("players/{id:long}")]
public async Task<ActionResult> DeletePlayer(long id, [FromBody] DeletePlayerRequest body)
{
if (id == 1)
return BadRequest(new { Error = "Cannot delete the system account." });
if (id == CurrentAdminId)
return BadRequest(new { Error = "Cannot delete your own admin account." });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin)
return BadRequest(new { Error = "Cannot delete an admin account. Demote it first." });
var expectedPhrase = $"DELETE {player.Id}";
if (!string.Equals(body.ConfirmUsername?.Trim(), player.Username, StringComparison.Ordinal)
|| !string.Equals(body.ConfirmPhrase?.Trim(), expectedPhrase, StringComparison.Ordinal))
return BadRequest(new { Error = $"Confirmation failed. Type @{player.Username} and {expectedPhrase} exactly." });
var reason = string.IsNullOrWhiteSpace(body.Reason)
? $"removed @{player.Username}"
: body.Reason.Trim();
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) ? 1L : CurrentAdminId;
await using var tx = await db.Database.BeginTransactionAsync();
var summary = await DeletePlayerDataAsync(id, systemPlayerId);
db.Players.Remove(player);
await LogAsync("delete_player", "player", id, $"{reason}; {summary}");
await db.SaveChangesAsync();
await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
playerPresence.Clear(id);
return Ok(new
{
deleted = id,
username = player.Username,
reassignedToPlayerId = systemPlayerId,
summary,
});
}
// ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason);
@@ -3044,6 +3094,133 @@ public class AdminController(
// ── Helpers ──────────────────────────────────────────────────────────
private async Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
{
var now = DateTime.UtcNow;
var deleted = 0;
var reassigned = 0;
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.ChatThreads
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Clubs
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.ClubAnnouncements
.Where(x => x.AuthorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.RoomBans
.Where(x => x.BannedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
reassigned += await db.RoomRoles
.Where(x => x.GrantedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
reassigned += await db.Rooms
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.RoomDataBlobs
.Where(x => x.UploadedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
reassigned += await db.RoomKeys
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Playlists
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.PlayerEventResponses
.Where(x => x.PlayerId == playerId
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
.ExecuteDeleteAsync();
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
var privateInstanceIds = await db.PrivateInstances
.Where(x => x.OwnerPlayerId == playerId)
.Select(x => x.Id)
.ToListAsync();
if (privateInstanceIds.Count > 0)
{
deleted += await db.PrivateInstanceInvitees
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
.ExecuteDeleteAsync();
deleted += await db.PrivateInstances
.Where(x => privateInstanceIds.Contains(x.Id))
.ExecuteDeleteAsync();
}
deleted += await db.GiftPackages
.Where(x => x.RecipientPlayerId == playerId)
.ExecuteDeleteAsync();
if (playerId <= int.MaxValue)
{
reassigned += await db.GiftPackages
.Where(x => x.FromPlayerId == (int)playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
}
reassigned += await db.Inventions
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.Photos
.Where(x => x.UploaderPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
.SetProperty(x => x.DeletedAt, now)
.SetProperty(x => x.IsPublic, false));
reassigned += await db.SignupCodes
.Where(x => x.RedeemedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
}
private Task LogAsync(string action, string targetType, long targetId, string reason)
{
db.AdminActions.Add(new AdminActionEntity
+109 -2
View File
@@ -219,7 +219,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
{tab === 'gift' && <GiftPanel playerId={data.id} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} onDeleted={onChanged} />}
</div>
)}
</Modal>
@@ -502,10 +502,11 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v
// ── Profile: display name + role flags ───────────────────────────────
function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
function ProfileTab({ data, onChanged, onDeleted }: { data: PlayerDetail; onChanged: () => void; onDeleted: () => void }) {
const toast = useToast();
const [displayName, setDisplayName] = useState(data.displayName);
const [username, setUsername] = useState(data.username);
const [deleteOpen, setDeleteOpen] = useState(false);
const [flags, setFlags] = useState({
isVerified: data.isVerified,
isDeveloper: data.isDeveloper,
@@ -598,6 +599,112 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () =>
</div>
<PasswordResetCard playerId={data.id} username={data.username} />
<div className="card !p-4 md:col-span-2 border-danger/40 bg-danger/5">
<h3 className="text-sm font-semibold text-danger mb-1">Remove account</h3>
<p className="text-xs text-ink-300 mb-3">
Deletes the account and personal rows. Rooms, inventions, clubs, and other durable authored content are reassigned to the system account.
</p>
<button
onClick={() => setDeleteOpen(true)}
className="btn-danger text-xs"
disabled={data.isAdmin || data.id === 1}
>Remove account</button>
{data.isAdmin && <p className="text-[11px] text-warn mt-2">Demote this admin before removing the account.</p>}
{data.id === 1 && <p className="text-[11px] text-warn mt-2">The system account cannot be removed.</p>}
</div>
<DeleteAccountModal
open={deleteOpen}
data={data}
onClose={() => setDeleteOpen(false)}
onDeleted={onDeleted}
/>
</div>
);
}
function DeleteAccountModal({ open, data, onClose, onDeleted }: {
open: boolean;
data: PlayerDetail;
onClose: () => void;
onDeleted: () => void;
}) {
const toast = useToast();
const [confirmUsername, setConfirmUsername] = useState('');
const [confirmPhrase, setConfirmPhrase] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const expectedPhrase = `DELETE ${data.id}`;
const valid = confirmUsername.trim() === data.username && confirmPhrase.trim() === expectedPhrase;
const remove = async () => {
if (!valid) return;
setBusy(true);
try {
await api(`/players/${data.id}`, {
method: 'DELETE',
body: {
ConfirmUsername: confirmUsername.trim(),
ConfirmPhrase: confirmPhrase.trim(),
Reason: reason.trim() || null,
},
});
toast.push(`@${data.username} removed`, 'success');
onClose();
onDeleted();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title="Remove account"
open={open}
onClose={onClose}
size="sm"
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={remove} className="btn-danger text-xs" disabled={busy || !valid}>
{busy ? 'Removing…' : 'Remove account'}
</button>
</>}
>
<div className="space-y-3 text-sm">
<p className="text-ink-200">
This removes <span className="font-mono">@{data.username}</span> and cannot be undone from the admin UI.
</p>
<label className="block">
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Type username</span>
<input
value={confirmUsername}
onChange={e => setConfirmUsername(e.target.value)}
placeholder={data.username}
className="input font-mono text-xs"
/>
</label>
<label className="block">
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Type confirmation phrase</span>
<input
value={confirmPhrase}
onChange={e => setConfirmPhrase(e.target.value)}
placeholder={expectedPhrase}
className="input font-mono text-xs"
/>
</label>
<label className="block">
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Reason</span>
<input
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Optional audit note"
className="input text-xs"
/>
</label>
</div>
</Modal>
);
}