mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 23:48:45 -07:00
feat: Refactor admin UI pages to support embedded mode and improve navigation
- Updated PlayerLogs, Players, Reports, Rooms, Settings, SignupCodes, and other pages to accept an optional `embedded` prop for better integration in various contexts. - Introduced a new Activity page consolidating audit logs and player request logs with tab navigation. - Added Content page for community board and loading tips with tab navigation. - Created Moderation page to unify player directory, bans, and reports under a single interface. - Implemented SettingsHome page to manage server settings and signup codes with tab navigation. - Enhanced the Join page to include password creation for new accounts. - Added global friends feature in Settings to allow all accounts to be friends without database entries. - Introduced RelationshipQueries service to manage effective friend resolution based on global friends toggle. - Added WireDates for consistent date handling in serialized data. - Created reusable Tabs component for consistent tab navigation across various pages.
This commit is contained in:
@@ -174,13 +174,21 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
// Self-heal: if the row exists but the bookkeeping fields
|
||||
// drift (e.g. someone manually edited Username), restore them
|
||||
// so admin tools always show "Coach" as the owner.
|
||||
if (existing.Username != SystemAccountUsername || existing.DisplayName != SystemAccountUsername)
|
||||
var nameDrift = existing.Username != SystemAccountUsername
|
||||
|| existing.DisplayName != SystemAccountUsername;
|
||||
// Heal a legacy year-9999 BannedUntil written before WireDates:
|
||||
// such a value corrupts the watch's local-timezone date parse if it
|
||||
// ever reaches a client (e.g. via /api/playersbanned). See WireDates.
|
||||
var banDrift = existing.BannedUntil.HasValue
|
||||
&& existing.BannedUntil.Value > WireDates.FarFuture;
|
||||
if (nameDrift || banDrift)
|
||||
{
|
||||
await db.Players
|
||||
.Where(p => p.Id == SystemAccountId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(p => p.Username, SystemAccountUsername)
|
||||
.SetProperty(p => p.DisplayName, SystemAccountUsername));
|
||||
.SetProperty(p => p.DisplayName, SystemAccountUsername)
|
||||
.SetProperty(p => p.BannedUntil, WireDates.FarFuture));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +212,10 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
// Far-future ban as a hard authorization backstop in case a
|
||||
// forged JWT ever names player 1 as the subject. BanCheckMiddleware
|
||||
// refuses any authenticated request when BannedUntil > now.
|
||||
BannedUntil = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
// WireDates.FarFuture (2099), NOT year 9999 — a 9999 BannedUntil
|
||||
// serialised to the watch overflows its local-timezone date parse
|
||||
// in some DST timezones and corrupts the response. See WireDates.
|
||||
BannedUntil = WireDates.FarFuture,
|
||||
PasswordHash = null,
|
||||
CreatedAt = new DateTime(2020, 3, 6, 0, 0, 0, DateTimeKind.Utc),
|
||||
LastSeenAt = new DateTime(2020, 3, 6, 0, 0, 0, DateTimeKind.Utc),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Effective-friend resolution that honors the
|
||||
/// <see cref="ServerSettingsEntity.GlobalFriendsEnabled"/> toggle.
|
||||
///
|
||||
/// When the toggle is ON, every account on the server is treated as a
|
||||
/// friend of every other account WITHOUT writing any relationship rows —
|
||||
/// the friend graph is synthesized at read time. This keeps a small private
|
||||
/// server frictionless (no searching + manual friend requests) and reverts
|
||||
/// instantly when the toggle flips off, since nothing was persisted.
|
||||
///
|
||||
/// Blocked relationships still suppress the pairing, and the system/coach
|
||||
/// account (<see cref="SystemAccountId"/>) is never offered as a friend.
|
||||
///
|
||||
/// Live propagation: flipping the toggle broadcasts
|
||||
/// <c>PushNotificationId.RelationshipsInvalid</c>, which the 2020 watch
|
||||
/// handles by calling <c>Relationships.RefreshList</c> — a full re-fetch of
|
||||
/// <c>api/relationships/v2/get</c> — so connected players see the change
|
||||
/// without relogging (verified against the readable March decompile:
|
||||
/// <c>RecNet/Relationships.txt → OnRelationshipsInvalid → RefreshList</c>).
|
||||
/// </summary>
|
||||
public static class RelationshipQueries
|
||||
{
|
||||
/// <summary>Coach/system account seeded at startup (Player.Id = 1).
|
||||
/// Excluded from the global-friends set so it never shows up as a
|
||||
/// friend in anyone's list.</summary>
|
||||
public const long SystemAccountId = 1;
|
||||
|
||||
/// <summary>True when <paramref name="username"/> is still an
|
||||
/// auto-generated placeholder name (<c>Player_</c> followed by digits
|
||||
/// only — see <c>PlayerService.GenerateUsername</c>). These are accounts
|
||||
/// that never set a real username; the global-friends toggle hides them
|
||||
/// from the synthesized "everyone" list so they don't clutter it. A
|
||||
/// genuine Friend row still surfaces them — only the synthetic pairing is
|
||||
/// suppressed.</summary>
|
||||
public static bool IsPlaceholderUsername(string? username)
|
||||
{
|
||||
const string prefix = "Player_";
|
||||
if (string.IsNullOrEmpty(username) ||
|
||||
!username.StartsWith(prefix, StringComparison.Ordinal)) return false;
|
||||
var suffix = username.AsSpan(prefix.Length);
|
||||
if (suffix.IsEmpty) return false;
|
||||
foreach (var c in suffix)
|
||||
if (!char.IsAsciiDigit(c)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The effective friend ids for <paramref name="me"/>. With the
|
||||
/// global-friends toggle off this is the real Friend graph; with it on
|
||||
/// it's every other (non-system, non-blocked) account, excluding
|
||||
/// placeholder <c>Player_NNN</c> accounts that aren't already real
|
||||
/// friends.</summary>
|
||||
public static async Task<List<long>> EffectiveFriendIdsAsync(
|
||||
DorkNetDbContext db, ServerSettingsService settings, long me)
|
||||
{
|
||||
if (!await settings.IsGlobalFriendsEnabledAsync())
|
||||
{
|
||||
return await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Friend &&
|
||||
(r.RequesterId == me || r.TargetId == me))
|
||||
.Select(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// Global friends: everyone except myself, the system account, and
|
||||
// anyone in a Blocked relationship with me (either direction).
|
||||
var blocked = await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Blocked &&
|
||||
(r.RequesterId == me || r.TargetId == me))
|
||||
.Select(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
var realFriendSet = (await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Friend &&
|
||||
(r.RequesterId == me || r.TargetId == me))
|
||||
.Select(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var candidates = await db.Players
|
||||
.Where(p => p.Id != me && p.Id != SystemAccountId && !blocked.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Username })
|
||||
.ToListAsync();
|
||||
|
||||
// Suppress placeholder accounts unless they're an actual friend.
|
||||
return candidates
|
||||
.Where(p => realFriendSet.Contains(p.Id) || !IsPlaceholderUsername(p.Username))
|
||||
.Select(p => p.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,26 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>When true, every account is treated as a friend of every
|
||||
/// other account (synthesized at read time — no relationship rows are
|
||||
/// written). See <see cref="ServerSettingsEntity.GlobalFriendsEnabled"/>
|
||||
/// and <see cref="RelationshipQueries"/>.</summary>
|
||||
public async Task<bool> IsGlobalFriendsEnabledAsync()
|
||||
{
|
||||
var row = await db.ServerSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == RowId);
|
||||
return row?.GlobalFriendsEnabled ?? false;
|
||||
}
|
||||
|
||||
public async Task<ServerSettingsEntity> SetGlobalFriendsEnabledAsync(bool enabled)
|
||||
{
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.GlobalFriendsEnabled = enabled;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
|
||||
@@ -10,10 +10,8 @@ namespace DorkNet.Server.Services;
|
||||
/// Backs the admin-issued signup-code flow. While the server has signups
|
||||
/// disabled, the only path to a new account is redeeming one of these on
|
||||
/// the public <c>/join</c> page. Codes are single-use, carry an admin
|
||||
/// descriptor, and can have an optional expiry. Redemption mints an
|
||||
/// account bound to the device id the player's game client reported (see
|
||||
/// <see cref="PendingDeviceEntity"/>), so the next launch logs straight
|
||||
/// in via the watch's device-id login.
|
||||
/// descriptor, and can have an optional expiry. Redemption mints a normal
|
||||
/// username/password account.
|
||||
/// </summary>
|
||||
public class SignupCodeService(DorkNetDbContext db, PlayerService players)
|
||||
{
|
||||
@@ -61,10 +59,10 @@ public class SignupCodeService(DorkNetDbContext db, PlayerService players)
|
||||
|
||||
public sealed record RedeemResult(bool Ok, string? Error, long? PlayerId, string? Username);
|
||||
|
||||
/// <summary>Redeem a code: validate it, then mint an account bound to
|
||||
/// <paramref name="deviceId"/> with the requested username. The code
|
||||
/// is consumed atomically with account creation.</summary>
|
||||
public async Task<RedeemResult> RedeemAsync(string? rawCode, string? rawUsername, string? deviceId)
|
||||
/// <summary>Redeem a code: validate it, then mint a password account
|
||||
/// with the requested username. The code is consumed atomically with
|
||||
/// account creation.</summary>
|
||||
public async Task<RedeemResult> RedeemAsync(string? rawCode, string? rawUsername, string? rawPassword)
|
||||
{
|
||||
var code = NormalizeCode(rawCode);
|
||||
if (string.IsNullOrEmpty(code)) return new(false, "missing_code", null, null);
|
||||
@@ -72,7 +70,9 @@ public class SignupCodeService(DorkNetDbContext db, PlayerService players)
|
||||
var username = (rawUsername ?? string.Empty).Trim();
|
||||
if (!IsValidUsername(username)) return new(false, "invalid_username", null, null);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return new(false, "missing_device", null, null);
|
||||
var password = rawPassword ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(password)) return new(false, "missing_password", null, null);
|
||||
if (password.Length < 8) return new(false, "password_too_short", null, null);
|
||||
|
||||
var entity = await db.SignupCodes.FirstOrDefaultAsync(c => c.Code == code);
|
||||
if (entity is null) return new(false, "invalid_code", null, null);
|
||||
@@ -83,31 +83,17 @@ public class SignupCodeService(DorkNetDbContext db, PlayerService players)
|
||||
if (await db.Players.AnyAsync(p => p.Username == username))
|
||||
return new(false, "username_taken", null, null);
|
||||
|
||||
// The device must not already own an account (otherwise the
|
||||
// player would launch into the existing one, not this fresh
|
||||
// account). Guides them to a different device / to just log in.
|
||||
if (await players.GetByDeviceAsync(deviceId) is not null)
|
||||
return new(false, "device_in_use", null, null);
|
||||
|
||||
var player = await players.GetOrCreateByDeviceAsync(
|
||||
deviceId: deviceId,
|
||||
var player = await players.CreateNewAccountAsync(
|
||||
deviceId: $"join-{Guid.NewGuid():N}",
|
||||
platform: 0,
|
||||
platformId: null,
|
||||
displayName: username);
|
||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11);
|
||||
|
||||
entity.RedeemedByPlayerId = player.Id;
|
||||
entity.RedeemedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// The device is now bound to a real account — drop any pending
|
||||
// refusal row so it stops showing on the /join picker.
|
||||
var pending = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
|
||||
if (pending is not null)
|
||||
{
|
||||
db.PendingDevices.Remove(pending);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return new(true, null, player.Id, player.Username);
|
||||
}
|
||||
|
||||
|
||||
@@ -778,8 +778,11 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
||||
{
|
||||
StorefrontType = storefrontType,
|
||||
StorefrontName = name,
|
||||
NextUpdate = "9999-12-31T00:00:00Z",
|
||||
NewUntil = "9999-12-31T00:00:00Z",
|
||||
// NOT year 9999: that overflows the watch's local-timezone date
|
||||
// parse in some DST timezones → "malformed RecNet response" and a
|
||||
// broken store for that player only. See WireDates.
|
||||
NextUpdate = WireDates.FarFutureIso,
|
||||
NewUntil = WireDates.FarFutureIso,
|
||||
StoreItems = storeItems,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user