Improve moderation case page and case partial (#900)

* Add pagination to moderation cases list and tweak case dismissal task

* Clean up case partial and add extended case status indicators

* Redirect back to cases list after dismissing a case

* Fix typo on cases list queue counter

* Fix dismissal queue counter

* Convert dismiss button check into pattern

* Turn down case dismissal task repeat interval to every 1 hour

* Use page 0 for case searching

* Implement pagination on the admin users list <3

* Fix pagination button padding and update colors to match existing role colors

* Fix typo in admin search placeholder

* Make cases searchable by user/slot ID instead of reason

Due to the current state of the moderation case entity, I can't directly query against the affected user name, so I've added the ability to search for the affected user/slot ID instead of reason.

* Actually apply the desired changes instead of just fixing the counts

* Grammatical nitpick in the search box placeholder
This commit is contained in:
koko 2023-09-22 14:53:53 -04:00 committed by GitHub
parent 1ddedae1fb
commit 3a2cdc9afe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 147 additions and 61 deletions

View file

@ -108,6 +108,6 @@ public class AdminUserController : ControllerBase
return this.Redirect($"/moderation/newCase?type={(int)CaseType.UserBan}&affectedId={id}"); return this.Redirect($"/moderation/newCase?type={(int)CaseType.UserBan}&affectedId={id}");
} }
return this.Redirect("/admin/users"); return this.Redirect("/admin/users/0");
} }
} }

View file

@ -34,6 +34,6 @@ public class ModerationCaseController : ControllerBase
await this.database.SaveChangesAsync(); await this.database.SaveChangesAsync();
return this.Ok(); return this.Redirect($"/moderation/cases/0");
} }
} }

View file

@ -27,7 +27,7 @@ public class AdminPanelPage : BaseLayout
if (user == null) return this.Redirect("~/login"); if (user == null) return this.Redirect("~/login");
if (!user.IsAdmin) return this.NotFound(); if (!user.IsAdmin) return this.NotFound();
this.Statistics.Add(new AdminPanelStatistic("Users", await StatisticsHelper.UserCount(this.Database), "/admin/users")); this.Statistics.Add(new AdminPanelStatistic("Users", await StatisticsHelper.UserCount(this.Database), "/admin/users/0"));
this.Statistics.Add(new AdminPanelStatistic("Slots", await StatisticsHelper.SlotCount(this.Database, new SlotQueryBuilder()))); this.Statistics.Add(new AdminPanelStatistic("Slots", await StatisticsHelper.SlotCount(this.Database, new SlotQueryBuilder())));
this.Statistics.Add(new AdminPanelStatistic("Photos", await StatisticsHelper.PhotoCount(this.Database))); this.Statistics.Add(new AdminPanelStatistic("Photos", await StatisticsHelper.PhotoCount(this.Database)));
this.Statistics.Add(new AdminPanelStatistic("API Keys", await StatisticsHelper.ApiKeyCount(this.Database), "/admin/keys")); this.Statistics.Add(new AdminPanelStatistic("API Keys", await StatisticsHelper.ApiKeyCount(this.Database), "/admin/keys"));

View file

@ -1,4 +1,4 @@
@page "/admin/users" @page "/admin/users/{pageNumber:int}"
@using LBPUnion.ProjectLighthouse.Types.Entities.Profile @using LBPUnion.ProjectLighthouse.Types.Entities.Profile
@using LBPUnion.ProjectLighthouse.Types.Users @using LBPUnion.ProjectLighthouse.Types.Users
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Admin.AdminPanelUsersPage @model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Admin.AdminPanelUsersPage
@ -11,6 +11,15 @@
<p>There are currently @Model.UserCount users registered to your instance.</p> <p>There are currently @Model.UserCount users registered to your instance.</p>
<p><b>Note:</b> Users are ordered by their permissions, then by most-recent-first.</p> <p><b>Note:</b> Users are ordered by their permissions, then by most-recent-first.</p>
<form action="/admin/users/0">
<div class="ui icon input">
<input type="text" autocomplete="off" name="name" placeholder="Search users..." value="@Model.SearchValue">
<i class="search icon"></i>
</div>
</form>
<div class="ui divider"></div>
<div class="ui grid"> <div class="ui grid">
@foreach (UserEntity user in Model.Users) @foreach (UserEntity user in Model.Users)
{ {
@ -19,22 +28,21 @@
switch (user.PermissionLevel) switch (user.PermissionLevel)
{ {
// jank but works
case PermissionLevel.Banned: case PermissionLevel.Banned:
{ {
color = "red"; color = "grey";
subtitle = $"Banned user! Reason: {user.BannedReason}"; subtitle = $"Banned user! Reason: {user.BannedReason}";
break; break;
} }
case PermissionLevel.Moderator: case PermissionLevel.Moderator:
{ {
color = "green"; color = "orange";
subtitle = "Moderator"; subtitle = "Moderator";
break; break;
} }
case PermissionLevel.Administrator: case PermissionLevel.Administrator:
{ {
color = "yellow"; color = "red";
subtitle = "Admin"; subtitle = "Admin";
break; break;
} }
@ -72,4 +80,16 @@
</div> </div>
</div> </div>
} }
</div> </div>
<br />
@if (Model.PageNumber != 0)
{
<a href="/admin/users/@(Model.PageNumber - 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Previous Page</a>
}
@(Model.PageNumber + 1) / @(Model.PageAmount)
@if (Model.PageNumber < Model.PageAmount - 1)
{
<a href="/admin/users/@(Model.PageNumber + 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Next Page</a>
}

View file

@ -1,4 +1,5 @@
#nullable enable #nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
@ -12,21 +13,39 @@ public class AdminPanelUsersPage : BaseLayout
public int UserCount; public int UserCount;
public List<UserEntity> Users = new(); public List<UserEntity> Users = new();
public int PageAmount;
public int PageNumber;
public string SearchValue = "";
public AdminPanelUsersPage(DatabaseContext database) : base(database) public AdminPanelUsersPage(DatabaseContext database) : base(database)
{} {}
public async Task<IActionResult> OnGet() public async Task<IActionResult> OnGet([FromRoute] int pageNumber, [FromQuery] string? name)
{ {
UserEntity? user = this.Database.UserFromWebRequest(this.Request); UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.Redirect("~/login"); if (user == null) return this.Redirect("~/login");
if (!user.IsAdmin) return this.NotFound(); if (!user.IsAdmin) return this.NotFound();
if (string.IsNullOrWhiteSpace(name)) name = "";
this.SearchValue = name.Replace(" ", string.Empty);
this.UserCount = await this.Database.Users.CountAsync(u => u.Username.Contains(this.SearchValue));
this.PageNumber = pageNumber;
this.PageAmount = Math.Max(1, (int)Math.Ceiling((double)this.UserCount / ServerStatics.PageSize));
if (this.PageNumber < 0 || this.PageNumber >= this.PageAmount)
return this.Redirect($"/admin/users/{Math.Clamp(this.PageNumber, 0, this.PageAmount - 1)}");
this.Users = await this.Database.Users this.Users = await this.Database.Users
.OrderByDescending(u => u.PermissionLevel) .OrderByDescending(u => u.PermissionLevel)
.ThenByDescending(u => u.UserId) .ThenByDescending(u => u.UserId)
.Where(u => u.Username.Contains(this.SearchValue))
.Skip(pageNumber * ServerStatics.PageSize)
.Take(ServerStatics.PageSize)
.ToListAsync(); .ToListAsync();
this.UserCount = this.Users.Count;
return this.Page(); return this.Page();
} }

View file

@ -10,11 +10,11 @@
string timeZone = Model.GetTimeZone(); string timeZone = Model.GetTimeZone();
} }
<p>There are @Model.CaseCount total cases, @Model.DismissedCaseCount of which have been dismissed.</p> <p>There are @Model.CaseCount total cases, @Model.ExpiredCaseCount of which are queued for dismissal, and @Model.DismissedCaseCount of which have been dismissed.</p>
<form action="/moderation/cases/0"> <form action="/moderation/cases/0">
<div class="ui icon input"> <div class="ui icon input">
<input type="text" autocomplete="off" name="name" placeholder="Search cases..." value="@Model.SearchValue"> <input type="text" autocomplete="off" name="name" placeholder="Search by affected ID..." value="@Model.SearchValue">
<i class="search icon"></i> <i class="search icon"></i>
</div> </div>
</form> </form>
@ -24,4 +24,14 @@
@foreach (ModerationCaseEntity @case in Model.Cases) @foreach (ModerationCaseEntity @case in Model.Cases)
{ {
@(await Html.PartialAsync("Partials/ModerationCasePartial", @case, ViewData.WithTime(timeZone))) @(await Html.PartialAsync("Partials/ModerationCasePartial", @case, ViewData.WithTime(timeZone)))
}
@if (Model.PageNumber != 0)
{
<a href="/moderation/cases/@(Model.PageNumber - 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Previous Page</a>
}
@(Model.PageNumber + 1) / @(Model.PageAmount)
@if (Model.PageNumber < Model.PageAmount - 1)
{
<a href="/moderation/cases/@(Model.PageNumber + 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Next Page</a>
} }

View file

@ -11,10 +11,11 @@ namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.Moderation;
public class CasePage : BaseLayout public class CasePage : BaseLayout
{ {
public CasePage(DatabaseContext database) : base(database) public CasePage(DatabaseContext database) : base(database)
{} { }
public List<ModerationCaseEntity> Cases = new(); public List<ModerationCaseEntity> Cases = new();
public int CaseCount; public int CaseCount;
public int ExpiredCaseCount;
public int DismissedCaseCount; public int DismissedCaseCount;
public int PageAmount; public int PageAmount;
@ -31,18 +32,30 @@ public class CasePage : BaseLayout
this.SearchValue = name.Replace(" ", string.Empty); this.SearchValue = name.Replace(" ", string.Empty);
this.Cases = await this.Database.Cases this.CaseCount =
.Include(c => c.Creator) await this.Database.Cases.CountAsync(c =>
.Include(c => c.Dismisser) c.AffectedId.ToString().Contains(this.SearchValue));
.OrderByDescending(c => c.CaseId) this.ExpiredCaseCount =
.ToListAsync(); await this.Database.Cases.CountAsync(c =>
c.AffectedId.ToString().Contains(this.SearchValue) && c.DismissedAt == null && c.ExpiresAt < DateTime.UtcNow);
this.CaseCount = await this.Database.Cases.CountAsync(c => c.Reason.Contains(this.SearchValue)); this.DismissedCaseCount =
this.DismissedCaseCount = await this.Database.Cases.CountAsync(c => c.Reason.Contains(this.SearchValue) && c.DismissedAt != null); await this.Database.Cases.CountAsync(c =>
c.AffectedId.ToString().Contains(this.SearchValue)&& c.DismissedAt != null);
this.PageNumber = pageNumber; this.PageNumber = pageNumber;
this.PageAmount = Math.Max(1, (int)Math.Ceiling((double)this.CaseCount / ServerStatics.PageSize)); this.PageAmount = Math.Max(1, (int)Math.Ceiling((double)this.CaseCount / ServerStatics.PageSize));
if (this.PageNumber < 0 || this.PageNumber >= this.PageAmount)
return this.Redirect($"/moderation/cases/{Math.Clamp(this.PageNumber, 0, this.PageAmount - 1)}");
this.Cases = await this.Database.Cases.Include(c => c.Creator)
.Include(c => c.Dismisser)
.Where(c => c.AffectedId.ToString().Contains(this.SearchValue))
.OrderByDescending(c => c.CaseId)
.Skip(pageNumber * ServerStatics.PageSize)
.Take(ServerStatics.PageSize)
.ToListAsync();
return this.Page(); return this.Page();
} }
} }

View file

@ -7,42 +7,19 @@
@inject DatabaseContext Database @inject DatabaseContext Database
@{ @{
string color = "blue";
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id; string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone); TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
if (Model.Expired) color = "yellow"; string color = "red";
if (Model.Dismissed) color = "green";
if (Model.Expired)
color = "yellow";
if (Model.Dismissed)
color = "green";
} }
<div class="ui @color segment"> <div class="ui @color segment">
<h2>Case #@Model.CaseId: @Model.Type</h2> <h2>Case #@Model.CaseId: @Model.Type</h2>
@if (Model.Dismissed)
{
Debug.Assert(Model.DismissedAt != null);
@if (Model.Dismisser != null)
{
<h3 class="ui @color header">
This case was dismissed by <a href="/user/@Model.Dismisser.UserId">@Model.DismisserUsername</a> on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
else
{
<h3 class="ui @color header">
This case was dismissed by @Model.DismisserUsername on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
}
else if (Model.Expired)
{
<h3 class="ui @color header">
This case expired on @TimeZoneInfo.ConvertTime(Model.ExpiresAt!.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
@if (Model.Creator != null && Model.Creator.Username.Length != 0) @if (Model.Creator != null && Model.Creator.Username.Length != 0)
{ {
@ -66,14 +43,15 @@
} }
</span><br> </span><br>
} }
@if (Model.Type.AffectsLevel()) @if (Model.Type.AffectsLevel())
{ {
SlotEntity? slot = await Model.GetSlotAsync(Database); SlotEntity? slot = await Model.GetSlotAsync(Database);
if (slot != null) if (slot != null)
{ {
<p><strong>Affected level:</strong> <a href="/slot/@slot.SlotId">@slot.Name</a></p> <p>
<strong>Affected level:</strong> <a href="/slot/@slot.SlotId">@slot.Name (@slot.SlotId)</a>
</p>
} }
} }
else if (Model.Type.AffectsUser()) else if (Model.Type.AffectsUser())
@ -81,10 +59,55 @@
UserEntity? user = await Model.GetUserAsync(Database); UserEntity? user = await Model.GetUserAsync(Database);
if (user != null) if (user != null)
{ {
<p><strong>Affected user:</strong> <a href="/user/@user.UserId">@user.Username</a></p> <p>
<strong>Affected user:</strong> <a href="/user/@user.UserId">@user.Username (@user.UserId)</a>
</p>
} }
} }
<h3>Case Status</h3>
@if (Model.Dismissed)
{
Debug.Assert(Model.DismissedAt != null);
@if (Model.Dismisser != null)
{
<div>
<i class="ui green icon check"></i>
<span class="ui green text">
This case was dismissed by <a href="/user/@Model.Dismisser.UserId">@Model.DismisserUsername</a> on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</span>
</div>
}
else
{
<div>
<i class="ui green icon check"></i>
<span class="ui green text">
This case was dismissed by @Model.DismisserUsername on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</span>
</div>
}
}
else if (Model.Expired)
{
<div>
<i class="ui orange icon clock"></i>
<span class="ui orange text">
This case expired on @TimeZoneInfo.ConvertTime(Model.ExpiresAt!.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt") and has been queued for dismissal.
</span>
</div>
}
else
{
<div>
<i class="ui red icon times"></i>
<span class="ui red text">
This case is currently active and will expire on @TimeZoneInfo.ConvertTime(Model.ExpiresAt!.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</span>
</div>
}
<h3>Reason</h3> <h3>Reason</h3>
@if (!string.IsNullOrWhiteSpace(Model.Reason)) @if (!string.IsNullOrWhiteSpace(Model.Reason))
{ {
@ -94,7 +117,7 @@
{ {
<pre><b>No reason was provided.</b></pre> <pre><b>No reason was provided.</b></pre>
} }
<h3>Moderator Notes</h3> <h3>Moderator Notes</h3>
@if (!string.IsNullOrWhiteSpace(Model.ModeratorNotes)) @if (!string.IsNullOrWhiteSpace(Model.ModeratorNotes))
{ {
@ -104,8 +127,8 @@
{ {
<pre><b>No notes were provided.</b></pre> <pre><b>No notes were provided.</b></pre>
} }
@if (!Model.Dismissed) @if (Model is { Dismissed: false, Expired: false, })
{ {
<a class="ui green small button" href="/moderation/case/@Model.CaseId/dismiss"> <a class="ui green small button" href="/moderation/case/@Model.CaseId/dismiss">
<i class="checkmark icon"></i> <i class="checkmark icon"></i>

View file

@ -14,7 +14,7 @@ namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.RepeatingTasks;
public class DismissExpiredCasesTask : IRepeatingTask public class DismissExpiredCasesTask : IRepeatingTask
{ {
public string Name => "Dismiss Expired Cases"; public string Name => "Dismiss Expired Cases";
public TimeSpan RepeatInterval => TimeSpan.FromHours(4); public TimeSpan RepeatInterval => TimeSpan.FromHours(1);
public DateTime LastRan { get; set; } public DateTime LastRan { get; set; }
public async Task Run(DatabaseContext database) public async Task Run(DatabaseContext database)
@ -31,7 +31,8 @@ public class DismissExpiredCasesTask : IRepeatingTask
foreach (ModerationCaseEntity @case in expiredCases) foreach (ModerationCaseEntity @case in expiredCases)
{ {
@case.DismissedAt = DateTime.Now; @case.DismissedAt = DateTime.UtcNow;
@case.DismisserUsername = "maintenance task";
Logger.Info($"Dismissed expired case {@case.CaseId}", LogArea.Maintenance); Logger.Info($"Dismissed expired case {@case.CaseId}", LogArea.Maintenance);
} }