R6 moderation in decePub: users and personas, accounts, report actions, deletions, suspended personas

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 05:41:10 +02:00
1 parent 4c88980374
commit c572471b88
30 files changed
+2604 -67

No files matched your search

+58 -4
View File
@@ -1,26 +1,58 @@
@inherits LocalizableComponentBase
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked
to one another: no server, PrivaPub's included, shows that they share a root. *@
@* The root's personas (/clientapi/avatar/private): each one used in turn, a new one made, and one deleted with the
login's password, asked first (everything public it had goes, with the groups it owns). One the server's staff
suspended or silenced says so; a suspended one can't be used until the suspension is lifted, but may be deleted.
Personas are never linked to one another: no server, PrivaPub's included, shows that they share a root. *@
<div class="flex flex-col gap-4 w-full" data-personas>
<div class="flex flex-col gap-2">
@if (CascadingState.Personas.Count == 0)
{
<p class="text-sm" data-personas-none>@Localizer["This login has no persona to act as yet: make one below."]</p>
}
@foreach (var persona in CascadingState.Personas)
{
<div class="flex items-center gap-2 min-w-0" data-persona="@persona.UserName">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 min-w-0" data-persona="@persona.UserName" data-persona-state="@StateOf(persona)">
<img class="size-8 rounded-full object-cover" src="@(persona.PictureURL ?? "imgs/icon-192.png")" alt="" />
<span class="flex-1 min-w-0 truncate"><b>@(persona.Name ?? persona.UserName)</b> <span class="text-xs opacity-70">@persona.Handle</span></span>
@if (persona.AccountState is ViewAvatarState.Suspended)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" title="@Localizer["Suspended by this server's staff: it can't act until that is lifted."]">
@Localizer["Suspended"]
</DBadge>
}
else if (persona.AccountState is ViewAvatarState.Silenced)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" title="@Localizer["Silenced by this server's staff: its posts stay out of public timelines, trends and search."]">
@Localizer["Silenced"]
</DBadge>
}
@if (persona.Id == CascadingState.PersonaId)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => Use(persona)">@Localizer["Use"]</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null || !CascadingState.CanUse(persona))" OnClick="() => Use(persona)">
@Localizer["Use"]
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => deleting = persona"
title="@Localizer["Delete this persona"]" aria-label="@Localizer["Delete this persona"]" data-persona-delete-own>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span class="hidden sm:inline">@Localizer["Delete"]</span>
</DButton>
</div>
}
</div>
@if (deleting is not null)
{
<ActDialog Question="@Localizer["Delete {0}?", deleting.Name ?? "@" + deleting.UserName]"
Text="@Localizer["Everything public it had goes, here and on the servers that hold it, with the groups it owns. Its name stays taken. It cannot be undone."]"
ConfirmText="@Localizer["Delete for good"]" Danger="true" Act="persona-delete" AskPassword="true" OnConfirm="Delete"
OnClosed="() => deleting = null" />
}
<EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["A new persona"]</b>
@@ -47,6 +79,15 @@
InsertAvatarForm form = new();
bool isSaving;
string switching;
// the persona whose deletion the dialog asks about
ViewAvatar deleting;
static string StateOf(ViewAvatar persona) => persona.AccountState switch
{
ViewAvatarState.Suspended => "suspended",
ViewAvatarState.Silenced => "silenced",
_ => "normal"
};
protected override void OnInitialized() => IsLoading = false;
@@ -59,6 +100,19 @@
Toast.AddResponseError(result);
}
async Task<WebResult> Delete(ActDialog.Answer answer)
{
var persona = deleting;
var result = await AuthService.DeletePersona(persona.Id, answer.Password);
if (result.IsInvalid)
return result;
// the session drops it, and acts as another persona when it was the current one (none left: make one below)
await CascadingState.PersonaDeleted(persona.Id);
Toast.AddResponseSuccess(Localizer["{0} is deleted.", persona.Name ?? "@" + persona.UserName]);
return result;
}
async Task Create()
{
isSaving = true;