Compare commits
6 commits
53006ea313
...
8edbc8bf1d
Author | SHA1 | Date | |
---|---|---|---|
8edbc8bf1d | |||
db22e35f0d | |||
9d3d46bf33 | |||
12eddb9949 | |||
8713279d3d | |||
dc9c11ec52 |
17 changed files with 399 additions and 13 deletions
|
@ -65,7 +65,7 @@ public class MembersController(
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
public const int MaxMemberCount = 500;
|
public const int MaxMemberCount = 1000;
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members")]
|
[HttpPost("/api/v2/users/@me/members")]
|
||||||
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
|
|
|
@ -43,7 +43,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
|
||||||
_ => limit,
|
_ => limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id);
|
IQueryable<AuditLogEntry> query = db
|
||||||
|
.AuditLog.Include(e => e.Report)
|
||||||
|
.OrderByDescending(e => e.Id);
|
||||||
|
|
||||||
if (before != null)
|
if (before != null)
|
||||||
query = query.Where(e => e.Id < before.Value);
|
query = query.Where(e => e.Id < before.Value);
|
||||||
|
|
96
Foxnouns.Backend/Controllers/Moderation/LookupController.cs
Normal file
96
Foxnouns.Backend/Controllers/Moderation/LookupController.cs
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers.Moderation;
|
||||||
|
|
||||||
|
[Route("/api/v2/moderation/lookup")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
[Limit(RequireModerator = true)]
|
||||||
|
public class LookupController(
|
||||||
|
DatabaseContext db,
|
||||||
|
UserRendererService userRenderer,
|
||||||
|
ModerationService moderationService,
|
||||||
|
ModerationRendererService moderationRenderer
|
||||||
|
) : ApiControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> QueryUsersAsync(
|
||||||
|
[FromBody] QueryUsersRequest req,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = db.Users.Select(u => new { u.Id, u.Username });
|
||||||
|
query = req.Fuzzy
|
||||||
|
? query.Where(u => u.Username.Contains(req.Query))
|
||||||
|
: query.Where(u => u.Username == req.Query);
|
||||||
|
|
||||||
|
var users = await query.OrderBy(u => u.Id).Take(100).ToListAsync(ct);
|
||||||
|
return Ok(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> QueryUserAsync(Snowflake id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
User user = await db.ResolveUserAsync(id, ct);
|
||||||
|
|
||||||
|
bool showSensitiveData = await moderationService.ShowSensitiveDataAsync(
|
||||||
|
CurrentUser!,
|
||||||
|
user,
|
||||||
|
ct
|
||||||
|
);
|
||||||
|
|
||||||
|
List<AuthMethod> authMethods = showSensitiveData
|
||||||
|
? await db
|
||||||
|
.AuthMethods.Where(a => a.UserId == user.Id)
|
||||||
|
.Include(a => a.FediverseApplication)
|
||||||
|
.ToListAsync(ct)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new QueryUserResponse(
|
||||||
|
User: await userRenderer.RenderUserAsync(
|
||||||
|
user,
|
||||||
|
renderMembers: false,
|
||||||
|
renderAuthMethods: false,
|
||||||
|
ct: ct
|
||||||
|
),
|
||||||
|
MemberListHidden: user.ListHidden,
|
||||||
|
LastActive: user.LastActive,
|
||||||
|
LastSidReroll: user.LastSidReroll,
|
||||||
|
Suspended: user is { Deleted: true, DeletedBy: not null },
|
||||||
|
Deleted: user.Deleted,
|
||||||
|
ShowSensitiveData: showSensitiveData,
|
||||||
|
AuthMethods: showSensitiveData
|
||||||
|
? authMethods.Select(UserRendererService.RenderAuthMethod)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/sensitive")]
|
||||||
|
public async Task<IActionResult> QuerySensitiveUserDataAsync(
|
||||||
|
Snowflake id,
|
||||||
|
[FromBody] QuerySensitiveUserDataRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
User user = await db.ResolveUserAsync(id);
|
||||||
|
|
||||||
|
// Don't let mods accidentally spam the audit log
|
||||||
|
bool alreadyAuthorized = await moderationService.ShowSensitiveDataAsync(CurrentUser!, user);
|
||||||
|
if (alreadyAuthorized)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
AuditLogEntry entry = await moderationService.QuerySensitiveDataAsync(
|
||||||
|
CurrentUser!,
|
||||||
|
user,
|
||||||
|
req.Reason
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,4 +41,5 @@ public enum AuditLogEntryType
|
||||||
WarnUser,
|
WarnUser,
|
||||||
WarnUserAndClearProfile,
|
WarnUserAndClearProfile,
|
||||||
SuspendUser,
|
SuspendUser,
|
||||||
|
QuerySensitiveUserData,
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Dto;
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
@ -41,12 +42,23 @@ public record AuditLogResponse(
|
||||||
AuditLogEntity? TargetUser,
|
AuditLogEntity? TargetUser,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
AuditLogEntity? TargetMember,
|
AuditLogEntity? TargetMember,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] PartialReport? Report,
|
||||||
AuditLogEntryType Type,
|
AuditLogEntryType Type,
|
||||||
string? Reason,
|
string? Reason,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record PartialReport(
|
||||||
|
Snowflake Id,
|
||||||
|
Snowflake ReporterId,
|
||||||
|
Snowflake TargetUserId,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
Snowflake? TargetMemberId,
|
||||||
|
ReportReason Reason,
|
||||||
|
string? Context,
|
||||||
|
ReportTargetType TargetType
|
||||||
|
);
|
||||||
|
|
||||||
public record NotificationResponse(
|
public record NotificationResponse(
|
||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
NotificationType Type,
|
NotificationType Type,
|
||||||
|
@ -83,3 +95,19 @@ public enum FieldsToClear
|
||||||
Flags,
|
Flags,
|
||||||
CustomPreferences,
|
CustomPreferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record QueryUsersRequest(string Query, bool Fuzzy);
|
||||||
|
|
||||||
|
public record QueryUserResponse(
|
||||||
|
UserResponse User,
|
||||||
|
bool MemberListHidden,
|
||||||
|
Instant LastActive,
|
||||||
|
Instant LastSidReroll,
|
||||||
|
bool Suspended,
|
||||||
|
bool Deleted,
|
||||||
|
bool ShowSensitiveData,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
IEnumerable<AuthMethodResponse>? AuthMethods
|
||||||
|
);
|
||||||
|
|
||||||
|
public record QuerySensitiveUserDataRequest(string Reason);
|
||||||
|
|
|
@ -46,12 +46,26 @@ public class ModerationRendererService(
|
||||||
|
|
||||||
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
|
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
|
||||||
{
|
{
|
||||||
|
PartialReport? report = null;
|
||||||
|
if (entry.Report != null)
|
||||||
|
{
|
||||||
|
report = new PartialReport(
|
||||||
|
entry.Report.Id,
|
||||||
|
entry.Report.ReporterId,
|
||||||
|
entry.Report.TargetUserId,
|
||||||
|
entry.Report.TargetMemberId,
|
||||||
|
entry.Report.Reason,
|
||||||
|
entry.Report.Context,
|
||||||
|
entry.Report.TargetType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new AuditLogResponse(
|
return new AuditLogResponse(
|
||||||
Id: entry.Id,
|
Id: entry.Id,
|
||||||
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
|
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
|
||||||
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
|
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
|
||||||
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
|
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
|
||||||
ReportId: entry.ReportId,
|
Report: report,
|
||||||
Type: entry.Type,
|
Type: entry.Type,
|
||||||
Reason: entry.Reason,
|
Reason: entry.Reason,
|
||||||
ClearedFields: entry.ClearedFields
|
ClearedFields: entry.ClearedFields
|
||||||
|
|
|
@ -18,6 +18,7 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Humanizer;
|
using Humanizer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
@ -63,6 +64,54 @@ public class ModerationService(
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AuditLogEntry> QuerySensitiveDataAsync(
|
||||||
|
User moderator,
|
||||||
|
User target,
|
||||||
|
string reason
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Moderator {ModeratorId} is querying sensitive data for {TargetId}",
|
||||||
|
moderator.Id,
|
||||||
|
target.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
var entry = new AuditLogEntry
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ModeratorId = moderator.Id,
|
||||||
|
ModeratorUsername = moderator.Username,
|
||||||
|
TargetUserId = target.Id,
|
||||||
|
TargetUsername = target.Username,
|
||||||
|
Type = AuditLogEntryType.QuerySensitiveUserData,
|
||||||
|
Reason = reason,
|
||||||
|
};
|
||||||
|
db.AuditLog.Add(entry);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ShowSensitiveDataAsync(
|
||||||
|
User moderator,
|
||||||
|
User target,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Snowflake cutoff = snowflakeGenerator.GenerateSnowflake(
|
||||||
|
clock.GetCurrentInstant() - Duration.FromDays(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await db.AuditLog.AnyAsync(
|
||||||
|
e =>
|
||||||
|
e.ModeratorId == moderator.Id
|
||||||
|
&& e.TargetUserId == target.Id
|
||||||
|
&& e.Type == AuditLogEntryType.QuerySensitiveUserData
|
||||||
|
&& e.Id > cutoff,
|
||||||
|
ct
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
|
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
|
||||||
User moderator,
|
User moderator,
|
||||||
User target,
|
User target,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Member } from "./member";
|
import type { Member } from "./member";
|
||||||
import type { PartialMember, PartialUser, User } from "./user";
|
import type { AuthMethod, PartialMember, PartialUser, User } from "./user";
|
||||||
|
|
||||||
export type CreateReportRequest = {
|
export type CreateReportRequest = {
|
||||||
reason: ReportReason;
|
reason: ReportReason;
|
||||||
|
@ -45,7 +45,7 @@ export type AuditLogEntry = {
|
||||||
moderator: AuditLogEntity;
|
moderator: AuditLogEntity;
|
||||||
target_user?: AuditLogEntity;
|
target_user?: AuditLogEntity;
|
||||||
target_member?: AuditLogEntity;
|
target_member?: AuditLogEntity;
|
||||||
report_id?: string;
|
report?: PartialReport;
|
||||||
type: AuditLogEntryType;
|
type: AuditLogEntryType;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
cleared_fields?: string[];
|
cleared_fields?: string[];
|
||||||
|
@ -58,4 +58,25 @@ export enum AuditLogEntryType {
|
||||||
WarnUser = "WARN_USER",
|
WarnUser = "WARN_USER",
|
||||||
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
|
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
|
||||||
SuspendUser = "SUSPEND_USER",
|
SuspendUser = "SUSPEND_USER",
|
||||||
|
QuerySensitiveUserData = "QUERY_SENSITIVE_USER_DATA",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PartialReport = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
target_user_id: string;
|
||||||
|
target_member_id?: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
context: string | null;
|
||||||
|
target_type: "USER" | "MEMBER";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueriedUser = {
|
||||||
|
user: User;
|
||||||
|
member_list_hidden: boolean;
|
||||||
|
last_active: string;
|
||||||
|
last_sid_reroll: string;
|
||||||
|
suspended: boolean;
|
||||||
|
deleted: boolean;
|
||||||
|
auth_methods?: AuthMethod[];
|
||||||
|
};
|
||||||
|
|
|
@ -12,10 +12,6 @@
|
||||||
let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
|
let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Audit log</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="card my-1 p-2">
|
<div class="card my-1 p-2">
|
||||||
<h6 class="d-flex">
|
<h6 class="d-flex">
|
||||||
<span class="flex-grow-1">
|
<span class="flex-grow-1">
|
||||||
|
@ -26,6 +22,8 @@
|
||||||
warned
|
warned
|
||||||
{:else if entry.type === "SUSPEND_USER"}
|
{:else if entry.type === "SUSPEND_USER"}
|
||||||
suspended
|
suspended
|
||||||
|
{:else if entry.type === "QUERY_SENSITIVE_USER_DATA"}
|
||||||
|
looked up sensitive data of
|
||||||
{:else}
|
{:else}
|
||||||
(unknown action <code>{entry.type}</code>)
|
(unknown action <code>{entry.type}</code>)
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -39,12 +37,31 @@
|
||||||
|
|
||||||
<small class="text-secondary">{date}</small>
|
<small class="text-secondary">{date}</small>
|
||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
|
{#if entry.type === "IGNORE_REPORT"}
|
||||||
|
{#if entry.report}
|
||||||
|
<details>
|
||||||
|
<summary>Report</summary>
|
||||||
|
<ul>
|
||||||
|
<li><strong>From:</strong> {entry.report.reporter_id}</li>
|
||||||
|
<li><strong>Target:</strong> {entry.report.target_user_id}</li>
|
||||||
|
<li><strong>Reason:</strong> {entry.report.reason}</li>
|
||||||
|
{#if entry.report.context}
|
||||||
|
<li><strong>Context:</strong> {entry.report.context}</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p><em>(the ignored report has been deleted)</em></p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if reason}
|
{#if reason}
|
||||||
<details>
|
<details>
|
||||||
<summary>Reason</summary>
|
<summary>Reason</summary>
|
||||||
{@html reason}
|
{@html reason}
|
||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
<em>(no reason given)</em>
|
<p><em>(no reason given)</em></p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { AuthMethod } from "$api/models";
|
import type { AuthMethod } from "$api/models";
|
||||||
|
|
||||||
type Props = { method: AuthMethod; canRemove: boolean };
|
type Props = { method: AuthMethod; canRemove: boolean; showType?: boolean };
|
||||||
let { method, canRemove }: Props = $props();
|
let { method, canRemove, showType }: Props = $props();
|
||||||
|
|
||||||
let name = $derived(
|
let name = $derived(
|
||||||
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
|
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
|
||||||
|
@ -14,6 +14,9 @@
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
{#if showType}
|
||||||
|
<code>{method.type}</code>:
|
||||||
|
{/if}
|
||||||
{name}
|
{name}
|
||||||
{#if showId}({method.remote_id}){/if}
|
{#if showId}({method.remote_id}){/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -41,6 +41,13 @@
|
||||||
>
|
>
|
||||||
Audit log
|
Audit log
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/lookup"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive("/admin/lookup", true)}
|
||||||
|
>
|
||||||
|
Lookup
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
|
|
|
@ -6,6 +6,10 @@
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin dashboard • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
<div class="row gx-3 gy-3">
|
<div class="row gx-3 gy-3">
|
||||||
|
|
|
@ -45,6 +45,10 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Audit log • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Audit log</h1>
|
<h1>Audit log</h1>
|
||||||
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
@ -68,6 +72,12 @@
|
||||||
<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
|
<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
|
||||||
Suspend user
|
Suspend user
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
href={addTypeFilter("QuerySensitiveUserData")}
|
||||||
|
active={data.type === "QuerySensitiveUserData"}
|
||||||
|
>
|
||||||
|
Query sensitive user data
|
||||||
|
</DropdownItem>
|
||||||
{#if data.type}
|
{#if data.type}
|
||||||
<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
|
<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
27
Foxnouns.Frontend/src/routes/admin/lookup/+page.server.ts
Normal file
27
Foxnouns.Frontend/src/routes/admin/lookup/+page.server.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const query = body.get("query") as string;
|
||||||
|
const fuzzy = body.get("fuzzy") === "yes";
|
||||||
|
|
||||||
|
const users = await apiRequest<Array<{ id: string; username: string }>>(
|
||||||
|
"POST",
|
||||||
|
"/moderation/lookup",
|
||||||
|
{
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
body: {
|
||||||
|
query,
|
||||||
|
fuzzy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fuzzy && users.length > 0) redirect(303, `/admin/lookup/${users[0].id}`);
|
||||||
|
|
||||||
|
return { users };
|
||||||
|
},
|
||||||
|
};
|
33
Foxnouns.Frontend/src/routes/admin/lookup/+page.svelte
Normal file
33
Foxnouns.Frontend/src/routes/admin/lookup/+page.svelte
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ActionData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { form: ActionData };
|
||||||
|
let { form }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Look up a user • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Look up a user</h1>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="input-group w-lg-50 mb-2">
|
||||||
|
<input type="text" class="form-control" name="query" placeholder="Query" required />
|
||||||
|
<button class="btn btn-primary" type="submit">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" value="yes" name="fuzzy" id="fuzzy" />
|
||||||
|
<label class="form-check-label" for="fuzzy">Fuzzy?</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
{#each form?.users || [] as user (user.id)}
|
||||||
|
<a href="/admin/lookup/{user.id}" class="list-group-item list-group-item-action">
|
||||||
|
{user.username} <span class="text-secondary">({user.id})</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="list-group-item">No results</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { QueriedUser } from "$api/models/moderation";
|
||||||
|
|
||||||
|
export const load = async ({ params, fetch, cookies }) => {
|
||||||
|
const user = await apiRequest<QueriedUser>("GET", `/moderation/lookup/${params.id}`, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user };
|
||||||
|
};
|
63
Foxnouns.Frontend/src/routes/admin/lookup/[id]/+page.svelte
Normal file
63
Foxnouns.Frontend/src/routes/admin/lookup/[id]/+page.svelte
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { idTimestamp } from "$lib";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import ProfileHeader from "$components/profile/ProfileHeader.svelte";
|
||||||
|
import ProfileFields from "$components/profile/ProfileFields.svelte";
|
||||||
|
import { mergePreferences } from "$api/models";
|
||||||
|
import AuthMethodRow from "$components/settings/AuthMethodRow.svelte";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let createdAt = $derived(idTimestamp(data.user.user.id));
|
||||||
|
let lastActive = $derived(DateTime.fromISO(data.user.last_active));
|
||||||
|
let lastSidReroll = $derived(DateTime.fromISO(data.user.last_sid_reroll));
|
||||||
|
|
||||||
|
let authMethods = $derived.by(() => {
|
||||||
|
if (!data.user.auth_methods) return undefined;
|
||||||
|
|
||||||
|
return data.user.auth_methods.sort((a, b) => a.type.localeCompare(b.type));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Looking up @{data.user.user.username} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h2>Basic profile</h2>
|
||||||
|
|
||||||
|
<ProfileHeader name="@{data.user.user.username}" profile={data.user.user} />
|
||||||
|
|
||||||
|
<ProfileFields
|
||||||
|
profile={data.user.user}
|
||||||
|
allPreferences={mergePreferences(data.user.user.custom_preferences)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2>Extra information</h2>
|
||||||
|
|
||||||
|
<table class="table table-striped table-hover table-bordered">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Created at</th>
|
||||||
|
<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last active</th>
|
||||||
|
<td>{lastActive.toLocaleString(DateTime.DATETIME_MED)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last SID reroll</th>
|
||||||
|
<td>{lastSidReroll.toLocaleString(DateTime.DATETIME_MED)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{#if authMethods}
|
||||||
|
<h2>Authentication methods</h2>
|
||||||
|
<div class="list-group">
|
||||||
|
{#each authMethods as method (method.id)}
|
||||||
|
<AuthMethodRow {method} canRemove={false} showType />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
Loading…
Add table
Reference in a new issue