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

+138 -3
View File
@@ -4,7 +4,9 @@
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's:
taken at sign-in and at start-up, saved there a moment after it changes here. *@
taken at sign-in and at start-up, saved there a moment after it changes here. A persona the staff suspended is never
acted as: it can't be chosen, and when PrivaPub refuses the current one a token (TokenAuthStateProvider.PersonaRefused)
the session moves to another persona of the login, or to none, and says why once. *@
<CascadingValue IsFixed=false Value=this>
@ChildContent
</CascadingValue>
@@ -24,6 +26,7 @@
[Inject] AuthService AuthService { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] CoalescingStringLocalizer Localizer { get; set; }
public bool IsOnline { get; private set; } = true;
public bool IsOffline => !IsOnline;
@@ -40,6 +43,10 @@
public string PersonaId { get; private set; }
/// <summary>That persona's Mastodon account, null until it is read.</summary>
public Account Persona { get; private set; }
/// <summary>The root handles reports and moderates accounts and posts (its JWT's policies); PrivaPub checks it anyway.</summary>
public bool IsModerator { get; private set; }
/// <summary>The root administers the server (its JWT's policies); PrivaPub checks it anyway.</summary>
public bool IsAdmin { get; private set; }
DotNetObjectReference<CascadingState> cascadingStateReference;
PeriodicTimer onlineTimer;
@@ -50,6 +57,8 @@
(string, short, short, bool, bool, bool, bool)? loginSettings;
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
CancellationTokenSource pendingSettingsSave;
/// <summary>A refused persona is being moved on from (MoveOnFromRefused); a refusal meanwhile is seen by its loop.</summary>
bool handlingRefusal;
protected override async Task OnInitializedAsync()
{
@@ -58,6 +67,7 @@
{
cascadingStateReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference);
AuthStateProvider.PersonaRefused += OnPersonaRefused;
var publicCacheData = await Storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken);
await UpdatePublicCache(publicCacheData ?? new(), cancellationToken);
@@ -197,10 +207,23 @@
var session = await AuthStateProvider.GetAuthData(cancellationToken);
RootUserName = session.UserName;
IsModerator = session.Policies.Contains(Policies.IsModerator);
IsAdmin = session.Policies.Contains(Policies.IsAdmin);
Personas = await AuthService.Personas(cancellationToken);
var personaId = Personas.Any(p => p.Id == session.PersonaId) ? session.PersonaId : Personas.FirstOrDefault()?.Id;
// what PrivaPub lists as usable is tried again, a persona refused before and given back since among them
foreach (var usable in Personas.Where(CanUse))
AuthStateProvider.Unrefuse(usable.Id);
var stored = Personas.FirstOrDefault(p => p.Id == session.PersonaId);
var personaId = stored is not null && CanUse(stored) ? stored.Id : Personas.FirstOrDefault(CanUse)?.Id;
if (stored is not null && !CanUse(stored))
Toast.AddWarning(personaId is null
? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", Shown(stored)]
: Localizer["{0} is suspended by this server's staff: you now act as {1}.", Shown(stored), Shown(Personas.First(p => p.Id == personaId))]);
if (personaId is null)
{
// the stored persona is listed suspended: no request acts as it (a list that couldn't be read forgets nothing)
if (stored is not null)
await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken);
PersonaId = null;
Persona = null;
IsSessionLoaded = true;
@@ -215,9 +238,21 @@
StateHasChanged();
}
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
/// <summary>Whether the persona may be acted as: not suspended by the server's staff (its login sees it so in its list).</summary>
public static bool CanUse(ViewAvatar persona) => persona.AccountState is not ViewAvatarState.Suspended;
/// <summary>A persona's name as the session's messages say it.</summary>
static string Shown(ViewAvatar persona) => string.IsNullOrWhiteSpace(persona.Name) ? "@" + persona.UserName : persona.Name;
/// <summary>Acts as another of the root's personas; every page showing persona data reloads. A suspended one is refused
/// here, before PrivaPub would refuse it a token.</summary>
public async Task<WebResult> SwitchPersona(string personaId, CancellationToken cancellationToken = default)
{
if (Personas.FirstOrDefault(p => p.Id == personaId) is { } listed && !CanUse(listed))
return new WebResult().Invalidate(Localizer["{0} is suspended by this server's staff.", Shown(listed)], 403);
// chosen again, it is tried again
AuthStateProvider.Unrefuse(personaId);
var result = await AuthService.UsePersona(personaId, cancellationToken);
if (result.IsInvalid)
return result;
@@ -237,6 +272,103 @@
StateHasChanged();
}
/// <summary>
/// One of the root's personas was deleted (from Settings, or by an administrator acting on their own login): its token is
/// forgotten and the personas read again; when it was the current one the session acts as another usable persona, or as
/// none when none is left (Settings then offers to make one).
/// </summary>
public async Task PersonaDeleted(string personaId, CancellationToken cancellationToken = default)
{
await AuthStateProvider.ForgetPersonaToken(personaId, cancellationToken);
Personas = await AuthService.Personas(cancellationToken);
if (personaId != PersonaId)
{
StateHasChanged();
return;
}
var next = Personas.FirstOrDefault(p => CanUse(p) && !AuthStateProvider.IsRefused(p.Id));
if (next is null)
{
await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken);
PersonaId = null;
Persona = null;
StateHasChanged();
return;
}
var result = await SwitchPersona(next.Id, cancellationToken);
if (result.IsInvalid)
Toast.AddResponseError(result);
StateHasChanged();
}
void OnPersonaRefused(string personaId) => _ = InvokeAsync(() => MoveOnFromRefused(disposeCancellation.Token));
/// <summary>
/// PrivaPub refused the current persona a token while the login's session is good: the personas are read again, and the
/// session acts as another usable one, or as none, saying why once. A refused persona is never tried again until it is
/// picked anew (SwitchPersona) or the session is read again (LoadSession), so this never loops on PrivaPub.
/// </summary>
async Task MoveOnFromRefused(CancellationToken cancellationToken)
{
if (handlingRefusal)
return;
handlingRefusal = true;
try
{
while (IsSignedIn && PersonaId is { } refusedId && AuthStateProvider.IsRefused(refusedId))
{
var known = Personas.FirstOrDefault(p => p.Id == refusedId);
Personas = await AuthService.Personas(cancellationToken);
// the login's own session ended meanwhile (HttpService signed it out): there is nothing to move to
if (!await AuthStateProvider.IsAuthenticatedAsync(cancellationToken))
{
ClearSession();
StateHasChanged();
return;
}
var refused = Personas.FirstOrDefault(p => p.Id == refusedId) ?? known;
var name = refused is null ? Persona?.Name ?? Persona?.Username : Shown(refused);
var suspended = refused is { AccountState: ViewAvatarState.Suspended };
var next = Personas.FirstOrDefault(p => CanUse(p) && !AuthStateProvider.IsRefused(p.Id));
if (next is null)
{
await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken);
PersonaId = null;
Persona = null;
Toast.AddWarning(suspended
? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", name]
: Localizer["{0} can no longer be used, and no other persona of this login can act now.", name]);
StateHasChanged();
return;
}
var switched = await SwitchPersona(next.Id, cancellationToken);
if (switched.IsInvalid && !AuthStateProvider.IsRefused(next.Id))
{
Toast.AddResponseError(switched);
return;
}
if (switched.IsInvalid)
continue;
Toast.AddWarning(suspended
? Localizer["{0} is suspended by this server's staff: you now act as {1}.", name, Shown(next)]
: Localizer["{0} can no longer be used: you now act as {1}.", name, Shown(next)]);
}
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(MoveOnFromRefused)}", cancellationToken);
}
finally
{
handlingRefusal = false;
}
}
/// <summary>The current persona's account as PrivaPub describes it after the persona edited its profile; the persona
/// chooser shows the new name and picture too.</summary>
public void PersonaChanged(Account account)
@@ -268,6 +400,8 @@
loginSettings = null;
IsSignedIn = false;
RootUserName = null;
IsModerator = false;
IsAdmin = false;
Personas = [];
PersonaId = null;
Persona = null;
@@ -332,6 +466,7 @@
public async ValueTask DisposeAsync()
{
AuthStateProvider.PersonaRefused -= OnPersonaRefused;
await disposeCancellation.CancelAsync();
disposeCancellation.Dispose();
CancelSettingsSave();