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:
1 parent
4c88980374
commit
c572471b88
30 files changed
+2604
-67
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user