mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Implement admin account deletion with confirmation and update documentation
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
@@ -19,21 +20,28 @@
|
||||
they would be derivative works of the game anyway, so we never
|
||||
reference them. Only MelonLoader's own DLLs are needed.
|
||||
|
||||
By default the project downloads the pinned MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
||||
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
||||
deliberately want to use a local override.
|
||||
By default the project downloads the pinned desktop MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
|
||||
LemonLoader builds, pass:
|
||||
|
||||
-p:TargetFrameworks=net8.0
|
||||
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
|
||||
|
||||
The reference folder follows the target framework unless explicitly
|
||||
overridden with -p:MelonLoaderReferenceTfm=...
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
@@ -47,19 +55,19 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -179,10 +179,16 @@ public class Mod : MelonMod
|
||||
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
||||
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
||||
// environment + accessToken AuthGetParameters.
|
||||
TryPatchByName("PUNNetworkManager", "FJOLIPKKIBE",
|
||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||
TryPatchByName("PUNNetworkManager", "EMGGKBALALH",
|
||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||
//
|
||||
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
|
||||
// return type first so a future platform/reseed does not make these
|
||||
// two critical hooks depend on the private method names.
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
|
||||
fallbackMethodName: "FJOLIPKKIBE",
|
||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
|
||||
fallbackMethodName: "EMGGKBALALH",
|
||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
||||
// string-like arg the IL2CPP method takes (declared `string` in
|
||||
// dump.cs but Il2CppInterop wraps it in a way that breaks
|
||||
@@ -723,6 +729,58 @@ public class Mod : MelonMod
|
||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
|
||||
string fallbackMethodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
bool logMiss = true)
|
||||
{
|
||||
var type = ResolveType(typeName);
|
||||
var label = $"{typeName}.<returns {returnTypeName}>";
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
var matches = new List<MethodInfo>();
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
|
||||
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
|
||||
}
|
||||
|
||||
if (matches.Count == 1)
|
||||
{
|
||||
var method = matches[0];
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.Count > 1)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var method in matches) names.Add(method.Name);
|
||||
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
else if (logMiss)
|
||||
{
|
||||
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
|
||||
}
|
||||
|
||||
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||
}
|
||||
|
||||
internal static Type? ResolveType(string name)
|
||||
{
|
||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||
@@ -795,6 +853,15 @@ public class Mod : MelonMod
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static bool ReturnTypeMatches(Type type, string requested)
|
||||
{
|
||||
var prefixed = "Il2Cpp" + requested;
|
||||
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||
if (type.Name == requested || type.Name == prefixed) return true;
|
||||
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||
{
|
||||
if (args is null) return $"{typeName}.{methodName}";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,38 @@ self-signed certs work. (First run prints "launch the game once"; do
|
||||
that, then re-run with `-ResumeBuild` to finish — see the script's
|
||||
`-?` help.)
|
||||
|
||||
### Standalone Quest build (experimental)
|
||||
|
||||
The December Quest APK is also IL2CPP and can build the same
|
||||
`DorkNet.ClientMod` source against LemonLoader's Android runtime. The
|
||||
current Quest path is still device-tested manually: build the mod as a
|
||||
net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then
|
||||
sideload the resigned APK.
|
||||
|
||||
From this branch:
|
||||
|
||||
```pwsh
|
||||
dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
|
||||
-p:TargetFrameworks=net8.0 `
|
||||
-p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
|
||||
```
|
||||
|
||||
Output lands at
|
||||
`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop
|
||||
builds still default to net6 and keep the historical flat output path.
|
||||
|
||||
Quest notes:
|
||||
|
||||
- Target package: `com.AgainstGravity.RecRoom`.
|
||||
- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false`
|
||||
for Quest builds.
|
||||
- The Dec Photon hooks are discovered from the unique
|
||||
`PUNNetworkManager` methods returning `AppSettings` and
|
||||
`AuthenticationValues`, with the known Dec obfuscated names as fallback.
|
||||
- Standalone APK patching/resigning is not yet wrapped by
|
||||
`install-melon.ps1`; this is the porting path, not a public one-click
|
||||
installer yet.
|
||||
|
||||
### Debug console (opt-in)
|
||||
|
||||
The 2020 client ships a built-in dev console
|
||||
|
||||
@@ -76,6 +76,13 @@ obscure the mirror relationship.
|
||||
| `Ns/` | `ns.{apex}/*` — service-URL registry the watch queries on startup |
|
||||
| `PlayerSettings/`, `Rooms/`, `Site/`, `Storage/`, `Strings/` | Smaller surfaces with self-explanatory scope |
|
||||
|
||||
Destructive admin account removal lives in `AdminController` under
|
||||
`DELETE api/admin/v1/players/{id}` and is surfaced from the Players
|
||||
detail modal. It requires two exact confirmations: the current username
|
||||
and `DELETE {id}`. The endpoint refuses system, self, and still-admin
|
||||
accounts; personal rows are deleted while durable authored content is
|
||||
reassigned to the system account.
|
||||
|
||||
---
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
Reference in New Issue
Block a user