Administration's Users & personas (administrators): logins searched and opened into their personas and groups; roles, bans, two-factor reset and deletion of a login; silence, suspension and deletion of a persona; a group deleted. Accounts (moderators): local personas and remote accounts searched or listed as moderated, silenced and suspended with a staff note and a warning. Reports resolve with an action. ActDialog asks for the note, the warning or the password and is the act's confirmation. A login deletes its own persona and the groups it owns with its password. Suspended personas can't be chosen; one refused a token mid-session moves the session to another, or to none, once. Warnings show in notifications, limited accounts on their profile, and moderators remove a post from its card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
297 lines
11 KiB
Plaintext
297 lines
11 KiB
Plaintext
@inherits LocalizableComponentBase
|
|
|
|
@* Accounts the staff moderate (/clientapi/moderator/accounts), for moderators and administrators: found by a name,
|
|
user@host or an actor's address (personas here, never their logins, and accounts elsewhere PrivaPub holds), and the
|
|
ones the staff silenced or suspended, most recently first, 50 at a time. Each is silenced, suspended or given back
|
|
with a note for the staff and, for a persona here, a warning it is told; an account elsewhere is told nothing. A
|
|
server silenced or suspended by a domain block holds its accounts back without their own silence, and says so. *@
|
|
<div class="flex flex-col gap-3 w-full" data-accounts>
|
|
@* its form attribute names no form, so Enter in the search submits nothing *@
|
|
<DInput TValue="string" class="w-full" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true" autocomplete="off"
|
|
form="accounts-search" placeholder="@Localizer["A name, user@host or an address"]" aria-label="@Localizer["Search accounts"]" data-accounts-search>
|
|
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
|
|
</DInput>
|
|
|
|
@if (query is { Length: > 0 })
|
|
{
|
|
<div class="flex flex-col gap-2" data-accounts-results>
|
|
@if (isSearching)
|
|
{
|
|
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
|
}
|
|
else if (found.Count == 0)
|
|
{
|
|
<p class="text-sm opacity-70" data-accounts-results-empty>@Localizer["No account found."]</p>
|
|
}
|
|
@foreach (var account in found)
|
|
{
|
|
@Row(account)
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<b class="text-sm mt-2">@Localizer["Silenced and suspended"]</b>
|
|
@if (IsLoading)
|
|
{
|
|
<p class="flex w-full items-center justify-center gap-2">
|
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
|
@Localizer["loading"]
|
|
</p>
|
|
}
|
|
else if (failed)
|
|
{
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="text-sm text-error">@Localizer["The accounts could not be read."]</p>
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Reload">@Localizer["Retry"]</DButton>
|
|
</div>
|
|
}
|
|
else if (moderated.Count == 0)
|
|
{
|
|
<p class="text-sm opacity-70" data-accounts-moderated-empty>@Localizer["No account is silenced or suspended."]</p>
|
|
}
|
|
<div class="flex flex-col gap-2" data-accounts-moderated>
|
|
@foreach (var account in moderated)
|
|
{
|
|
@Row(account)
|
|
}
|
|
</div>
|
|
@if (next is not null)
|
|
{
|
|
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-accounts-more>
|
|
@if (isLoadingMore)
|
|
{
|
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
|
}
|
|
<span>@Localizer["Load more"]</span>
|
|
</DButton>
|
|
}
|
|
</div>
|
|
|
|
@if (acting is not null)
|
|
{
|
|
<ActDialog Question="@Question" Text="@ActText" ConfirmText="@ConfirmText" Danger="@(action is ModerationActions.Silence or ModerationActions.Suspend)"
|
|
Act="@action" Staff="true" AskNote="true" AskWarning="@(acting.IsLocal && action is ModerationActions.Silence or ModerationActions.Suspend)"
|
|
OnConfirm="Moderate" OnClosed="CloseAct" />
|
|
}
|
|
|
|
@code {
|
|
const int PageSize = 50;
|
|
const int SearchSize = 20;
|
|
|
|
[Inject] ModerationService ModerationService { get; set; }
|
|
|
|
string query;
|
|
List<ViewModeratedAccount> found = [];
|
|
List<ViewModeratedAccount> moderated = [];
|
|
// the offset of the moderated list's next page; null on the last
|
|
int? next;
|
|
bool failed;
|
|
bool isSearching;
|
|
bool isLoadingMore;
|
|
int searching;
|
|
// the account acted on and how, while the dialog asks
|
|
ViewModeratedAccount acting;
|
|
string action;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
if (CascadingState.IsOffline)
|
|
{
|
|
WhenOnline(LoadData);
|
|
StartOnlinePolling();
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
await LoadData();
|
|
IsLoading = false;
|
|
}
|
|
|
|
async Task LoadData()
|
|
{
|
|
var page = await ModerationService.ModeratedAccounts(0, PageSize);
|
|
failed = page is null;
|
|
moderated = page?.Items ?? [];
|
|
next = page?.Next;
|
|
}
|
|
|
|
async Task Reload()
|
|
{
|
|
IsLoading = true;
|
|
await LoadData();
|
|
IsLoading = false;
|
|
}
|
|
|
|
async Task More()
|
|
{
|
|
isLoadingMore = true;
|
|
var page = await ModerationService.ModeratedAccounts(next ?? 0, PageSize);
|
|
isLoadingMore = false;
|
|
if (page is null)
|
|
{
|
|
Toast.AddResponseError(Localizer["The accounts could not be read."]);
|
|
return;
|
|
}
|
|
|
|
var known = moderated.Select(a => a.Id).ToHashSet();
|
|
moderated.AddRange(page.Items.Where(a => known.Add(a.Id)));
|
|
next = page.Next;
|
|
}
|
|
|
|
async Task Search(string text)
|
|
{
|
|
query = text;
|
|
var lookup = ++searching;
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
found = [];
|
|
isSearching = false;
|
|
return;
|
|
}
|
|
|
|
isSearching = true;
|
|
await Task.Delay(300);
|
|
if (lookup != searching)
|
|
return;
|
|
var page = await ModerationService.SearchAccounts(text.Trim(), SearchSize);
|
|
if (lookup != searching)
|
|
return;
|
|
found = page?.Items ?? [];
|
|
isSearching = false;
|
|
}
|
|
|
|
static string State(ViewModeratedAccount account) =>
|
|
account.SuspendedAt is not null ? "suspended" : account.SilencedAt is not null ? "silenced" : "normal";
|
|
|
|
RenderFragment Row(ViewModeratedAccount account) => @<article class="flex flex-col gap-2 p-2 md:p-3 rounded-lg neo-raised neo-2xs"
|
|
data-moderated-account="@account.Handle" data-account-state="@State(account)" data-account-id="@account.Id">
|
|
<div class="flex flex-wrap items-center gap-2 min-w-0">
|
|
<span class="flex flex-col min-w-0 flex-1">
|
|
<a class="font-semibold truncate link link-hover pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(account.Id)}")"
|
|
title="@account.Url">@(account.DisplayName is { Length: > 0 } displayName ? displayName : account.UserName)</a>
|
|
<span class="text-xs opacity-70 truncate">@("@" + account.Handle)</span>
|
|
</span>
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@(account.IsLocal ? Localizer["here"] : account.Domain)</DBadge>
|
|
@if (account.SilencedAt is { } silencedAt)
|
|
{
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" title="@silencedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"
|
|
data-account-silenced>@Localizer["Silenced"]</DBadge>
|
|
}
|
|
@if (account.SuspendedAt is { } suspendedAt)
|
|
{
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" title="@suspendedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"
|
|
data-account-suspended>@Localizer["Suspended"]</DBadge>
|
|
}
|
|
@if (account.DomainSilenced)
|
|
{
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" Color="DColor.Warning" data-account-domain-silenced>@Localizer["its server is silenced"]</DBadge>
|
|
}
|
|
@if (account.DomainSuspended)
|
|
{
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" Color="DColor.Error" data-account-domain-suspended>@Localizer["its server is suspended"]</DBadge>
|
|
}
|
|
</div>
|
|
@if (account.Note is { Length: > 0 } staffNote)
|
|
{
|
|
<p class="text-xs opacity-80 whitespace-pre-line break-words" data-account-note><b>@Localizer["Staff note"]</b> @staffNote</p>
|
|
}
|
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
|
@if (account.SilencedAt is null)
|
|
{
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Silence)" data-account-silence>
|
|
<i class="ion-md-volume-off text-warning" aria-hidden="true"></i><span>@Localizer["Silence"]</span>
|
|
</DButton>
|
|
}
|
|
else
|
|
{
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Unsilence)" data-account-unsilence>
|
|
<i class="ion-md-volume-high" aria-hidden="true"></i><span>@Localizer["Unsilence"]</span>
|
|
</DButton>
|
|
}
|
|
@if (account.SuspendedAt is null)
|
|
{
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Suspend)" data-account-suspend>
|
|
<i class="ion-md-remove-circle text-error" aria-hidden="true"></i><span>@Localizer["Suspend"]</span>
|
|
</DButton>
|
|
}
|
|
else
|
|
{
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Unsuspend)" data-account-unsuspend>
|
|
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Unsuspend"]</span>
|
|
</DButton>
|
|
}
|
|
</div>
|
|
</article>;
|
|
|
|
string Name => acting?.DisplayName is { Length: > 0 } displayName ? displayName : "@" + acting?.Handle;
|
|
|
|
string Question => action switch
|
|
{
|
|
ModerationActions.Silence => Localizer["Silence {0}?", Name],
|
|
ModerationActions.Unsilence => Localizer["Unsilence {0}?", Name],
|
|
ModerationActions.Suspend => Localizer["Suspend {0}?", Name],
|
|
_ => Localizer["Unsuspend {0}?", Name]
|
|
};
|
|
|
|
string ActText => (action, acting?.IsLocal == true) switch
|
|
{
|
|
(ModerationActions.Silence, _) => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."],
|
|
(ModerationActions.Suspend, true) => Localizer["The persona can't act or be seen here any more, and its sessions end; nothing new is told to other servers. It reads the warning once the suspension is lifted."],
|
|
(ModerationActions.Suspend, false) => Localizer["Nothing of it is shown here any more, what it sends is dropped and nothing is delivered to it. Its server is told nothing."],
|
|
(ModerationActions.Unsilence, _) => Localizer["Its posts are listed again."],
|
|
_ => Localizer["It is given back as it was, but the sessions a suspension ended."]
|
|
};
|
|
|
|
string ConfirmText => action switch
|
|
{
|
|
ModerationActions.Silence => Localizer["Silence"],
|
|
ModerationActions.Unsilence => Localizer["Unsilence"],
|
|
ModerationActions.Suspend => Localizer["Suspend"],
|
|
_ => Localizer["Unsuspend"]
|
|
};
|
|
|
|
void Ask(ViewModeratedAccount account, string act) => (acting, action) = (account, act);
|
|
|
|
void CloseAct() => (acting, action) = (null, null);
|
|
|
|
async Task<WebResult> Moderate(ActDialog.Answer answer)
|
|
{
|
|
var account = acting;
|
|
var act = action;
|
|
var result = await ModerationService.Moderate(new ModerateAccountForm
|
|
{
|
|
AccountId = account.Id,
|
|
Action = act,
|
|
Note = answer.Note,
|
|
Warning = account.IsLocal && act is ModerationActions.Silence or ModerationActions.Suspend ? answer.Warning : null
|
|
});
|
|
if (result.IsInvalid)
|
|
return result;
|
|
|
|
Changed(result.Data as ViewModeratedAccount ?? account);
|
|
Toast.AddResponseSuccess(act switch
|
|
{
|
|
ModerationActions.Silence => Localizer["{0} is silenced.", "@" + account.Handle],
|
|
ModerationActions.Unsilence => Localizer["{0} is no longer silenced.", "@" + account.Handle],
|
|
ModerationActions.Suspend => Localizer["{0} is suspended.", "@" + account.Handle],
|
|
_ => Localizer["{0} is no longer suspended.", "@" + account.Handle]
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// the account as PrivaPub has it now, in both lists: one no longer held back leaves the moderated list, one newly held
|
|
// back joins it at the top
|
|
void Changed(ViewModeratedAccount account)
|
|
{
|
|
found = [.. found.Select(a => a.Id == account.Id ? account : a)];
|
|
var held = account.SilencedAt is not null || account.SuspendedAt is not null;
|
|
var listed = moderated.Any(a => a.Id == account.Id);
|
|
moderated = held switch
|
|
{
|
|
true when listed => [.. moderated.Select(a => a.Id == account.Id ? account : a)],
|
|
true => [account, .. moderated],
|
|
false => [.. moderated.Where(a => a.Id != account.Id)]
|
|
};
|
|
}
|
|
}
|