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
+2601 -64

No files matched your search

+67 -15
View File
@@ -160,8 +160,15 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
- No history entries are pushed. - No history entries are pushed.
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in - **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a `MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the group, dropping a scheduled post, signing out, and for an administrator deleting an announcement, removing the
server's thumbnail. server's thumbnail, and changing a login's role, banning or unbanning it, resetting its two-factor sign-in and deleting
it. An act that takes words or a password asks in `Components/ActDialog.razor` instead, which is its confirmation:
what it does, the fields, Cancel (`data-act-cancel`) and the act (`data-act-submit`); a failure (a wrong password) stays
in the dialog. The staff's (`Staff`: `data-moderate-confirm="{act}"`, a note `data-moderate-note`, a warning
`data-moderate-warning`, the administrator's own password `data-admin-password`) are silencing and suspending an account
and undoing it, removing a post, resolving a report, deleting a persona or a group; the login's own
(`data-own-confirm="{act}"`, its password `data-own-password`) are deleting one of its personas and a group a persona
owns.
- **On a touch screen:** - **On a touch screen:**
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are - a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
pinned to the globe with a mouse only; pinned to the globe with a mouse only;
@@ -207,9 +214,16 @@ all of them in every theme. When you add or change one:
- `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage; - `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
- the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`); - the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`);
- `IsOnline` / `IsOffline`, polled every 10 seconds; - `IsOnline` / `IsOffline`, polled every 10 seconds;
- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), with - the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), `IsModerator`
`LoadSession`, `SwitchPersona` and `SignOut`. A persona change re-renders every subscriber; pages compare and `IsAdmin` (the JWT's policies), with `LoadSession`, `SwitchPersona`, `PersonaDeleted` and `SignOut`. A persona
`PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed; change re-renders every subscriber; pages compare `PersonaId` and reload what belongs to the persona. The JWT is renewed
hourly when needed;
- a persona the staff suspended (`ViewAvatar.AccountState` `Suspended` in `/clientapi/avatar/private/list`, `CanUse`) is
never acted as: `LoadSession` skips it, `SwitchPersona` refuses it, and the switcher and Login's chooser show it
disabled (`data-persona-state`). When PrivaPub refuses the current persona a token while the JWT is good (suspended
meanwhile, or gone), `HttpService` marks it refused (`TokenAuthStateProvider.Refuse`; no request acts as it again, each
answered `FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again)
and `CascadingState` reads the personas again and moves to another usable one, or to none, saying why once;
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast; - `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`. - `LogFromJs`, which JS calls back into through a `DotNetObjectReference`.
@@ -300,7 +314,10 @@ Never redeclare the base's members in a component.
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService` following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups), (`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups),
`InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's `InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
`InstanceAdminController`), `AboutService` (what the server says of itself to anyone: the instance API, its documents `InstanceAdminController`), `ModerationService` (the staff's acts: accounts found, silenced, suspended and given back,
posts removed and reports resolved on `/clientapi/moderator`; logins read with their personas and groups, their role,
ban, two-factor reset and deletion, and a persona's or a group's deletion with the administrator's password on
`/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and
`ProvenanceService`. `ProvenanceService`.
@@ -334,7 +351,8 @@ Never redeclare the base's members in a component.
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
persona pins and unpins its own public and unlisted posts from the card's menu. persona pins and unpins its own public and unlisted posts from the card's menu.
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one), An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
linking the new account's profile. linking the new account's profile. One the staff silenced, or whose server is silenced, says Limited
(`Account.Limited`, `data-account-limited`).
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words, Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
@@ -352,8 +370,9 @@ Never redeclare the base's members in a component.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`): - **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out. left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out; the
Only a group's owner and moderators see who is in it. ones it owns deleted with the login's password (`data-group-delete-own`, `ActDialog`; `/clientapi/group/delete`,
`DeleteGroupForm`: its members are told, its name stays taken). Only a group's owner and moderators see who is in it.
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members - **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub. menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
@@ -415,7 +434,11 @@ Never redeclare the base's members in a component.
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
everywhere", see **One sign-in**); or the whole of it everywhere", see **One sign-in**); or the whole of it
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
signs out), the persona's profile (`ProfileEditor`: signs out), its personas (`PersonasEditor`: each used, shown silenced or suspended (`data-persona-state`; a suspended
one can't be used), and deleted with the login's password (`data-persona-delete-own`, `ActDialog`;
`/clientapi/avatar/private/delete`, `DeletePersonaForm`), the groups it owns with it; `CascadingState.PersonaDeleted`
forgets its token and, when it was the current one, acts as another, or as none, and a new one is made below), the
persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file, `update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot, and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
@@ -430,9 +453,29 @@ Never redeclare the base's members in a component.
`data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation `data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`), log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`),
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
(`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators: (`backups`). Moderators also see Accounts (`accounts`), administrators Users & personas (`users`). PrivaPub checks the
`/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until role on every call (moderators: `/clientapi/moderator`, administrators: `/clientapi/admin`, checked against its database
then they have no section. too). Custom emoji come with R7: until then they have no section.
- **Users & personas** (`UsersPanel`, `data-users`, `/clientapi/admin/roots`; the one place a login is shown with its
personas): newest first, 50 at a time (`data-users-more`), found by a login's or a persona's name (`data-users-search`;
`data-users-reload`). Each login (`data-user="{login name}"`, `data-user-role` user|moderator|admin,
`data-user-banned` true|false) shows its badges (`data-user-you`, `data-user-banned-badge`, `data-user-totp`,
`data-user-email` none|verified|unverified) and, unless it is the administrator's own, its role
(`data-user-role-select`), `data-user-ban` or `data-user-unban`, `data-user-totp-reset` (with two-factor on) and
`data-user-delete`, each asked first (ConfirmService; a role PrivaPub refuses, one's own or the last administrator's,
is a 409 and the select shows the role again). `data-user-open` shows its personas (`data-user-personas`): each
(`data-persona="{userName}"`, `data-persona-state` normal|silenced|suspended) with `data-persona-silence` or
`-unsilence`, `-suspend` or `-unsuspend` and `data-persona-delete` (the administrator's password), and the groups it
owns (`data-group="{userName}"`, `data-group-delete`, the password). An act on the administrator's own persona moves
the session with it (`CascadingState.PersonaDeleted`, `ReloadPersonas`).
- **Accounts** (`AccountsPanel`, `data-accounts`, `/clientapi/moderator/accounts`): accounts here and elsewhere found by a
name, `user@host` or an address (`data-accounts-search`, results in `data-accounts-results`), and the ones silenced or
suspended, most recently first (`data-accounts-moderated`, 50 at a time, `data-accounts-more`). Each
(`data-moderated-account="{handle}"`, `data-account-state` normal|silenced|suspended, `data-account-id`) shows what the
staff did (`data-account-silenced`, `-suspended`, its server's block `data-account-domain-silenced`, `-suspended`, the
staff's note `data-account-note`) and `data-account-silence` or `-unsilence`, `-suspend` or `-unsuspend`, asked in
`ActDialog` with a note and, for a persona here and a silence or suspension, a warning it is told (Mastodon's
`moderation_warning`; an account elsewhere is told nothing).
- **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule - **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule
left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it
reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media
@@ -475,7 +518,14 @@ Never redeclare the base's members in a component.
(`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note (`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note
(`data-log-note`). `data-log-reload`. (`data-log-note`). `data-log-reload`.
- **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their - **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their
texts, a removed rule's too. texts, a removed rule's too. "Resolve and…" (`data-report-resolve`, a `DDropdown SheetOnNarrow`) resolves it with
`data-report-resolve-action` none, silence, suspend (with an account) or remove_posts (with posts), asked in
`ActDialog` with a note and a warning (`ResolveReportForm`; the act comes first, so one that fails leaves the report
open). A resolved report shows what was done and its note (`data-report-resolution="{action}"`,
`data-report-resolution-note`).
- **A post from the feed:** a moderator removes another's post from its card's menu (`data-moderate-remove-post`,
`ActDialog`, `/clientapi/moderator/posts/remove`): one written here is deleted as its author would, and its author
warned; a copy of one from elsewhere is hidden here. The page takes the card out.
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels' - **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the `Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
ledger's events and the server's own days. Counted per server, never per account. ledger's events and the server's own days. Counted per server, never per account.
@@ -499,7 +549,9 @@ Never redeclare the base's members in a component.
what notifications from people the persona does not follow, people who do not follow it, new accounts, private what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait. A notification is dismissed from its line (`data-notification-dismiss`, links there while requests wait. A warning from the staff (`moderation_warning`, `Notification.ModerationWarning`) shows
what they did and what they wrote (`data-notification-warning="{action}"`, `data-notification-warning-text`). A
notification is dismissed from its line (`data-notification-dismiss`,
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for `TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
one alone), and the page takes that item out (`Content.OnDismissed`). one alone), and the page takes that item out (`Content.OnDismissed`).
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a - **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
+12
View File
@@ -99,6 +99,18 @@ namespace decePubClient;
[JsonSerializable(typeof(List<ViewRelay>))] [JsonSerializable(typeof(List<ViewRelay>))]
[JsonSerializable(typeof(RelayForm))] [JsonSerializable(typeof(RelayForm))]
[JsonSerializable(typeof(ViewAdminActionsPage))] [JsonSerializable(typeof(ViewAdminActionsPage))]
[JsonSerializable(typeof(UsersIds))]
[JsonSerializable(typeof(ModerateAccountForm))]
[JsonSerializable(typeof(RemovePostForm))]
[JsonSerializable(typeof(ResolveReportForm))]
[JsonSerializable(typeof(ViewModeratedAccount))]
[JsonSerializable(typeof(ViewModeratedAccountsPage))]
[JsonSerializable(typeof(ViewAdminRoot))]
[JsonSerializable(typeof(ViewAdminRootsPage))]
[JsonSerializable(typeof(RootRoleForm))]
[JsonSerializable(typeof(AdminPasswordForm))]
[JsonSerializable(typeof(DeletePersonaForm))]
[JsonSerializable(typeof(DeleteGroupForm))]
public partial class ClientJsonContext : JsonSerializerContext public partial class ClientJsonContext : JsonSerializerContext
{ {
} }
+113
View File
@@ -0,0 +1,113 @@
@inherits LocalizableComponentBase
@* Asks before an act that takes words or a password: what it does, then the fields it needs, then a confirm button. The
staff's acts (Staff) take a note only the staff read (data-moderate-note), a warning the persona is told
(data-moderate-warning) and, to delete, the administrator's own password (data-admin-password); the dialog is
data-moderate-confirm="{act}". A login's own deletions take its password (data-own-password) in data-own-confirm="{act}".
OnConfirm does the act: a failure stays in the dialog (a wrong password is typed again), a success closes it. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-moderate-confirm="@(Staff ? Act : null)" data-own-confirm="@(Staff ? null : Act)">
<Heading>@Question</Heading>
<ChildContent>
<div class="flex flex-col gap-3 mt-2 text-sm">
@if (Text is { Length: > 0 })
{
<p class="whitespace-pre-line">@Text</p>
}
@if (AskNote)
{
<label class="flex flex-col gap-1">
<span class="font-semibold">@Localizer["A note for the staff (optional)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="note" Immediate="true" rows="2" autocomplete="off" maxlength="1000"
aria-label="@Localizer["A note for the staff (optional)"]" data-moderate-note />
<span class="text-xs opacity-70">@Localizer["Only the staff read it, in the moderation log."]</span>
</label>
}
@if (AskWarning)
{
<label class="flex flex-col gap-1">
<span class="font-semibold">@Localizer["What the persona is told (optional)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="warning" Immediate="true" rows="3" autocomplete="off" maxlength="1000"
aria-label="@Localizer["What the persona is told (optional)"]" data-moderate-warning />
<span class="text-xs opacity-70">@(WarningHint ?? Localizer["It reaches the persona as a warning from this server's staff."])</span>
</label>
}
@if (AskPassword)
{
<DInput TValue="string" Type="password" class="w-full" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
aria-label="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
data-admin-password="@(Staff ? "" : null)" data-own-password="@(Staff ? null : "")" />
}
<ErrorViewer AdditionalError="@error" />
</div>
</ChildContent>
<Actions>
<DButton class="max-md:flex-1" OnClick="Cancel" data-act-cancel>@Localizer["Cancel"]</DButton>
<DButton class="max-md:flex-1" Color="@(Danger ? DColor.Error : DColor.Primary)"
Disabled="@(IsDefaultDisabled || isActing || (AskPassword && string.IsNullOrEmpty(password)))" OnClick="Confirm" data-act-submit>
@if (isActing)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@ConfirmText</span>
</DButton>
</Actions>
</DModal>
@code {
/// <summary>The question the dialog asks, its heading.</summary>
[Parameter, EditorRequired] public string Question { get; set; }
/// <summary>What the act does, in words.</summary>
[Parameter] public string Text { get; set; }
[Parameter, EditorRequired] public string ConfirmText { get; set; }
/// <summary>The confirm button shows as a danger (it deletes or cuts something off).</summary>
[Parameter] public bool Danger { get; set; }
/// <summary>The act's name in the dialog's data attribute (silence, persona-delete, ...).</summary>
[Parameter, EditorRequired] public string Act { get; set; }
/// <summary>A staff act (data-moderate-confirm, the administrator's password) or the login's own (data-own-confirm).</summary>
[Parameter] public bool Staff { get; set; }
[Parameter] public bool AskNote { get; set; }
[Parameter] public bool AskWarning { get; set; }
/// <summary>Who reads the warning, when it isn't simply the persona (an account elsewhere is told nothing).</summary>
[Parameter] public string WarningHint { get; set; }
[Parameter] public bool AskPassword { get; set; }
/// <summary>Does the act with what was typed; an invalid result stays in the dialog, a valid one closes it.</summary>
[Parameter, EditorRequired] public Func<Answer, Task<WebResult>> OnConfirm { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>What was typed: the note and the warning trimmed, null when empty; the password as typed.</summary>
public sealed record Answer(string Note, string Warning, string Password);
string note;
string warning;
string password;
string error;
bool isActing;
protected override void OnInitialized() => IsLoading = false;
async Task Confirm()
{
error = default;
isActing = true;
var result = await OnConfirm(new Answer(Trimmed(note), Trimmed(warning), password));
isActing = false;
if (result.IsInvalid)
{
// a cancelled request says nothing; anything else is said here, where it can be fixed
if (result.StatusCode is not 410)
error = result.ErrorMessage;
password = default;
return;
}
await OnClosed.InvokeAsync();
}
static string Trimmed(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
Task Cancel() => OnClosed.InvokeAsync();
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+296
View File
@@ -0,0 +1,296 @@
@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)]
};
}
}
+10
View File
@@ -156,6 +156,16 @@
"root.delete" => Localizer["Login deleted"], "root.delete" => Localizer["Login deleted"],
"root.reset_totp" => Localizer["Two-factor sign-in reset"], "root.reset_totp" => Localizer["Two-factor sign-in reset"],
"persona.move" => Localizer["Persona moved to another login"], "persona.move" => Localizer["Persona moved to another login"],
"root.role" => Localizer["Login's role changed"],
"root.ban" => Localizer["Login banned"],
"root.unban" => Localizer["Login's ban lifted"],
"persona.silence" or "account.silence" => Localizer["Account silenced"],
"persona.unsilence" or "account.unsilence" => Localizer["Account no longer silenced"],
"persona.suspend" or "account.suspend" => Localizer["Account suspended"],
"persona.unsuspend" or "account.unsuspend" => Localizer["Account no longer suspended"],
"persona.delete" => Localizer["Persona deleted"],
"group.delete" => Localizer["Group deleted"],
"post.remove" => Localizer["Post removed"],
_ => action _ => action
}; };
} }
+113 -13
View File
@@ -2,7 +2,9 @@
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another @* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
server, about an account and some of its posts, a violation with the server's rules it names (their texts as PrivaPub server, about an account and some of its posts, a violation with the server's rules it names (their texts as PrivaPub
keeps them, a removed rule's too); resolved here. *@ keeps them, a removed rule's too). "Resolve and…" resolves one with what the staff does about it (nothing more, the
account silenced or suspended, the reported posts removed), a note for the staff and a warning for a persona here; the
act comes first, so one that fails leaves the report open. A resolved report shows what was done and its note. *@
<div class="flex flex-col gap-3 w-full" data-reports> <div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" /> <DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@@ -33,16 +35,54 @@
<span class="flex-1"></span> <span class="flex-1"></span>
@if (!report.IsResolved) @if (!report.IsResolved)
{ {
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)" <DDropdown IsOpen="@(menuOpen == report.ID)" IsOpenChanged="open => menuOpen = open ? report.ID : null" Align="DDropdownAlign.End"
data-report-resolve> CloseOnContentClick="true" SheetOnNarrow="true"
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span> TriggerClass="@(menuOpen == report.ID ? "btn btn-sm btn-success btn-active" : "btn btn-sm btn-success")"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box w-64 max-md:w-full bg-base-100 neo-raised neo-xs" data-report-resolve>
<Trigger>
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Resolve and…"]</span>
</Trigger>
<ChildContent>
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.None)"
data-report-resolve-action="none">
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Resolve only"]</span>
</DButton> </DButton>
@if (report.TargetAccountId is { Length: > 0 })
{
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.Silence)"
data-report-resolve-action="silence">
<i class="ion-md-volume-off text-warning" aria-hidden="true"></i><span>@Localizer["Silence the account"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.Suspend)"
data-report-resolve-action="suspend">
<i class="ion-md-remove-circle text-error" aria-hidden="true"></i><span>@Localizer["Suspend the account"]</span>
</DButton>
}
@if (report.PostIds.Count > 0)
{
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.RemovePosts)"
data-report-resolve-action="remove_posts">
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Remove the reported posts"]</span>
</DButton>
}
</ChildContent>
</DDropdown>
} }
else if (report.ResolvedAt is { } resolvedAt) else if (report.ResolvedAt is { } resolvedAt)
{ {
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span> <span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
} }
</div> </div>
@if (report.IsResolved && report.Resolution is { Length: > 0 } resolution)
{
<div class="flex flex-col gap-1 text-sm rounded-lg neo-inset neo-2xs p-2" data-report-resolution="@resolution">
<b>@ResolutionName(resolution)</b>
@if (report.ResolutionNote is { Length: > 0 } resolutionNote)
{
<span class="text-xs opacity-80 whitespace-pre-line break-words" data-report-resolution-note>@resolutionNote</span>
}
</div>
}
<p class="text-sm"> <p class="text-sm">
<b>@Localizer["About"]</b> <b>@Localizer["About"]</b>
@if (report.TargetAccountId is { Length: > 0 } targetId) @if (report.TargetAccountId is { Length: > 0 } targetId)
@@ -104,12 +144,24 @@
} }
</div> </div>
@if (resolving is not null)
{
<ActDialog Question="@ResolveQuestion" Text="@ResolveText" ConfirmText="@ResolveConfirm" Danger="@(resolveAction != ModerationActions.None)"
Act="@("resolve-" + resolveAction)" Staff="true" AskNote="true" AskWarning="@(resolveAction != ModerationActions.None)"
WarningHint="@Localizer["Only a persona here is told; an account elsewhere is told nothing."]" OnConfirm="Resolve" OnClosed="CloseResolve" />
}
@code { @code {
[Inject] AdminService AdminService { get; set; } [Inject] AdminService AdminService { get; set; }
[Inject] ModerationService ModerationService { get; set; }
List<ViewReport> reports = []; List<ViewReport> reports = [];
bool resolved; bool resolved;
HashSet<string> busy = []; // the report whose "Resolve and…" menu is open
string menuOpen;
// the report being resolved and how, while its dialog asks
ViewReport resolving;
string resolveAction;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -147,18 +199,66 @@
_ => Localizer["Other"] _ => Localizer["Other"]
}; };
async Task Resolve(ViewReport report) string ResolutionName(string action) => action switch
{ {
busy.Add(report.ID); ModerationActions.Silence => Localizer["The account was silenced."],
var result = await AdminService.Resolve(report.ID); ModerationActions.Suspend => Localizer["The account was suspended."],
busy.Remove(report.ID); ModerationActions.RemovePosts => Localizer["The reported posts were removed."],
if (result.IsInvalid) _ => Localizer["Resolved without an act."]
};
string ResolveQuestion => resolveAction switch
{ {
Toast.AddResponseError(result); ModerationActions.Silence => Localizer["Resolve it and silence the account?"],
return; ModerationActions.Suspend => Localizer["Resolve it and suspend the account?"],
ModerationActions.RemovePosts => Localizer["Resolve it and remove the reported posts?"],
_ => Localizer["Resolve the report?"]
};
string ResolveText => resolveAction switch
{
ModerationActions.Silence => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."],
ModerationActions.Suspend => Localizer["Nothing of it is shown here any more and it can't act; nothing new is told to other servers. It can be lifted later."],
ModerationActions.RemovePosts => Localizer["A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here."],
_ => Localizer["Nothing is done to the account or its posts."]
};
string ResolveConfirm => resolveAction switch
{
ModerationActions.Silence => Localizer["Silence"],
ModerationActions.Suspend => Localizer["Suspend"],
ModerationActions.RemovePosts => Localizer["Remove the posts"],
_ => Localizer["Resolve"]
};
void AskResolve(ViewReport report, string action)
{
menuOpen = null;
resolving = report;
resolveAction = action;
} }
reports.Remove(report); void CloseResolve() => (resolving, resolveAction) = (null, null);
async Task<WebResult> Resolve(ActDialog.Answer answer)
{
var report = resolving;
var action = resolveAction;
var result = await ModerationService.ResolveReport(report.ID, new ResolveReportForm
{
Action = action,
Note = answer.Note,
Warning = action == ModerationActions.None ? null : answer.Warning
});
if (result.IsInvalid)
return result;
// shown at once as PrivaPub now keeps it: resolved, with what was done and its note
report.IsResolved = true;
report.ResolvedAt = DateTime.UtcNow;
report.Resolution = action;
report.ResolutionNote = answer.Note;
Toast.AddResponseSuccess(Localizer["Report resolved."]); Toast.AddResponseSuccess(Localizer["Report resolved."]);
return result;
} }
} }
+502
View File
@@ -0,0 +1,502 @@
@inherits LocalizableComponentBase
@* The logins, for administrators (/clientapi/admin/roots): the one place a login is shown with its personas and the
groups they own. Newest first, 50 at a time, found by a login's name or a persona's. A login's role (user, moderator,
administrator), its ban, its two-factor sign-in and its deletion are asked first (ConfirmService); never one's own,
and never the last administrator's role (PrivaPub answers 409). A persona is silenced, suspended or given back with a
note for the staff and a warning it is told; a persona or a group is deleted with the administrator's own password.
Every act is kept in the staff's log. *@
<div class="flex flex-col gap-3 w-full" data-users>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
@* its form attribute names no form, so Enter in the search submits nothing *@
<DInput TValue="string" class="flex-1 min-w-48" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true" autocomplete="off"
form="users-search" placeholder="@Localizer["A login's name, or one of its personas'"]" aria-label="@Localizer["Search logins"]" data-users-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="Reload" data-users-reload>
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
</DButton>
</div>
@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 logins could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Reload">@Localizer["Retry"]</DButton>
</div>
}
else if (roots.Count == 0)
{
<p class="text-sm opacity-70" data-users-empty>@Localizer["No login found."]</p>
}
<div class="flex flex-col gap-2">
@foreach (var root in roots)
{
<article @key="root.Id" class="flex flex-col gap-2 p-2 md:p-3 rounded-lg neo-raised neo-2xs" data-user="@root.UserName" data-user-role="@root.Role"
data-user-banned="@(root.IsBanned ? "true" : "false")">
<div class="flex flex-wrap items-center gap-2 min-w-0">
<i class="ion-md-key opacity-60" aria-hidden="true"></i>
<b class="truncate min-w-0">@root.UserName</b>
@if (root.IsYou)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary" data-user-you>@Localizer["you"]</DBadge>
}
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@RoleColor(root.Role)">@RoleName(root.Role)</DBadge>
@if (root.IsBanned)
{
<DBadge Size="DSize.Sm" Color="DColor.Error" data-user-banned-badge>@Localizer["banned"]</DBadge>
}
@if (root.HasTotp)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-user-totp>@Localizer["two-factor"]</DBadge>
}
@if (!root.HasEmail)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" data-user-email="none">@Localizer["no email address"]</DBadge>
}
else if (root.IsEmailValidated)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-user-email="verified">@Localizer["email verified"]</DBadge>
}
else
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-user-email="unverified">@Localizer["email not verified"]</DBadge>
}
<span class="flex-1"></span>
<span class="text-xs opacity-70" title="@root.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@root.CreatedAt.GetPassedTime(Localizer)</span>
</div>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Active="@opened.Contains(root.Id)" OnClick="() => Toggle(root)" aria-expanded="@(opened.Contains(root.Id) ? "true" : "false")"
data-user-open>
<i class="@(opened.Contains(root.Id) ? "ion-md-arrow-dropup" : "ion-md-arrow-dropdown")" aria-hidden="true"></i>
<span>@Localizer["Personas: {0}", root.Personas.Count]</span>
</DButton>
<span class="flex-1"></span>
@if (!root.IsYou)
{
<DSelect @key="@($"{root.Id}:{roleRenders}")" TValue="string" Size="DSize.Sm" Value="@root.Role" ValueChanged="role => ChangeRole(root, role)"
ValueExpression="() => root.Role" disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" aria-label="@Localizer["Role"]"
data-user-role-select>
<option value="user">@RoleName("user")</option>
<option value="moderator">@RoleName("moderator")</option>
<option value="admin">@RoleName("admin")</option>
</DSelect>
@if (root.IsBanned)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => Unban(root)" data-user-unban>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Unban"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => Ban(root)" data-user-ban>
<i class="ion-md-hand text-warning" aria-hidden="true"></i><span>@Localizer["Ban"]</span>
</DButton>
}
@if (root.HasTotp)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => ResetTotp(root)" data-user-totp-reset>
<i class="ion-md-unlock" aria-hidden="true"></i><span>@Localizer["Reset two-factor"]</span>
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => DeleteRoot(root)" data-user-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete the login"]</span>
</DButton>
}
</div>
@if (opened.Contains(root.Id))
{
<div class="flex flex-col gap-2 ps-2 md:ps-4 border-s-2 border-base-300" data-user-personas>
@if (root.Personas.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This login has no persona."]</p>
}
@foreach (var persona in root.Personas)
{
<div class="flex flex-col gap-2 p-2 rounded-lg neo-inset neo-2xs" data-persona="@persona.UserName" data-persona-state="@State(persona)">
<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(persona.Id)}")">
@(persona.DisplayName is { Length: > 0 } displayName ? displayName : persona.UserName)
</a>
<span class="text-xs opacity-70 truncate">@("@" + persona.Handle)</span>
</span>
@if (persona.SilencedAt is { } silencedAt)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" title="@silencedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Silenced"]
</DBadge>
}
@if (persona.SuspendedAt is { } suspendedAt)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" title="@suspendedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Suspended"]
</DBadge>
}
</div>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@if (persona.SilencedAt is null)
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Silence)" data-persona-silence>
<i class="ion-md-volume-off text-warning" aria-hidden="true"></i><span>@Localizer["Silence"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Unsilence)" data-persona-unsilence>
<i class="ion-md-volume-high" aria-hidden="true"></i><span>@Localizer["Unsilence"]</span>
</DButton>
}
@if (persona.SuspendedAt is null)
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Suspend)" data-persona-suspend>
<i class="ion-md-remove-circle text-error" aria-hidden="true"></i><span>@Localizer["Suspend"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Unsuspend)" data-persona-unsuspend>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Unsuspend"]</span>
</DButton>
}
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, PersonaDelete)" data-persona-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
</DButton>
</div>
@foreach (var group in persona.Groups)
{
<div class="flex flex-wrap items-center gap-2 min-w-0 text-sm" data-group="@group.UserName">
<i class="@(group.IsCommunity ? "ion-md-globe" : "ion-md-lock") opacity-70" aria-hidden="true"></i>
<span class="flex-1 min-w-0 truncate">
<b>@(group.Name ?? group.UserName)</b> <span class="text-xs opacity-70">@("@" + group.UserName)</span>
</span>
<span class="text-xs tabular-nums opacity-70" title="@Localizer["Members"]"><i class="ion-md-person" aria-hidden="true"></i> @group.MembersCount</span>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, group, GroupDelete)" title="@Localizer["Delete the group"]"
aria-label="@Localizer["Delete the group"]" data-group-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span class="hidden sm:inline">@Localizer["Delete the group"]</span>
</DButton>
</div>
}
</div>
}
</div>
}
</article>
}
</div>
@if (next is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-users-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
@if (act is not null)
{
<ActDialog Question="@Question" Text="@ActText" ConfirmText="@ConfirmText" Danger="@(act is not (ModerationActions.Unsilence or ModerationActions.Unsuspend))"
Act="@act" Staff="true" AskNote="true" AskWarning="@(act is ModerationActions.Silence or ModerationActions.Suspend)"
AskPassword="@(act is PersonaDelete or GroupDelete)" OnConfirm="Act" OnClosed="CloseAct" />
}
@code {
const int PageSize = 50;
const string PersonaDelete = "persona-delete";
const string GroupDelete = "group-delete";
[Inject] ModerationService ModerationService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
string query;
List<ViewAdminRoot> roots = [];
// the next page's `before`; null on the last
string next;
bool failed;
bool isLoadingMore;
int searching;
// the logins whose personas are shown
HashSet<string> opened = [];
// the logins being changed
HashSet<string> busy = [];
// rebuilds the role selects, so one whose change was cancelled shows the role again
int roleRenders;
// the act the dialog asks about (a persona's silence or suspension, a persona's or a group's deletion) and its targets
string act;
ViewAdminRoot actingRoot;
ViewAdminPersona actingPersona;
ViewAdminGroup actingGroup;
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.Roots(query?.Trim(), null, PageSize);
failed = page is null;
roots = page?.Items ?? [];
next = page?.Next;
}
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
async Task Search(string text)
{
query = text;
var lookup = ++searching;
await Task.Delay(300);
if (lookup != searching)
return;
IsLoading = true;
await LoadData();
if (lookup != searching)
return;
IsLoading = false;
}
async Task More()
{
isLoadingMore = true;
var page = await ModerationService.Roots(query?.Trim(), next, PageSize);
isLoadingMore = false;
if (page is null)
{
Toast.AddResponseError(Localizer["The logins could not be read."]);
return;
}
var known = roots.Select(r => r.Id).ToHashSet();
roots.AddRange(page.Items.Where(r => known.Add(r.Id)));
next = page.Next;
}
void Toggle(ViewAdminRoot root)
{
if (!opened.Remove(root.Id))
opened.Add(root.Id);
}
static string State(ViewAdminPersona persona) =>
persona.SuspendedAt is not null ? "suspended" : persona.SilencedAt is not null ? "silenced" : "normal";
string RoleName(string role) => role switch
{
"admin" => Localizer["administrator"],
"moderator" => Localizer["moderator"],
_ => Localizer["user"]
};
static DColor RoleColor(string role) => role switch
{
"admin" => DColor.Secondary,
"moderator" => DColor.Info,
_ => DColor.Neutral
};
// the login as PrivaPub has it now, in its place; one gone (deleted) leaves the list
async Task Refresh(ViewAdminRoot root)
{
var now = await ModerationService.Root(root.Id);
roots = now is null ? [.. roots.Where(r => r.Id != root.Id)] : [.. roots.Select(r => r.Id == root.Id ? now : r)];
}
async Task ChangeRole(ViewAdminRoot root, string role)
{
var (question, text) = role switch
{
"admin" => (Localizer["Make {0} an administrator?", root.UserName],
Localizer["Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups."]),
"moderator" => (Localizer["Make {0} a moderator?", root.UserName],
Localizer["Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings."]),
_ => (Localizer["Make {0} a user?", root.UserName], Localizer["It keeps its personas, and loses what the staff do."])
};
if (!await Confirm.Ask(question, text, Localizer["Change the role"], true))
{
roleRenders++;
return;
}
busy.Add(root.Id);
var result = await ModerationService.SetRole(root.Id, role);
busy.Remove(root.Id);
if (result.IsInvalid)
{
roleRenders++;
Toast.AddResponseError(result);
return;
}
var changed = result.Data as ViewAdminRoot;
if (changed is not null)
roots = [.. roots.Select(r => r.Id == root.Id ? changed : r)];
Toast.AddResponseSuccess(Localizer["{0} is now {1}.", root.UserName, RoleName(changed?.Role ?? role)]);
}
async Task Ban(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Ban {0}?", root.UserName],
Localizer["Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted."],
Localizer["Ban"], true))
return;
await Run(root, ModerationService.Ban, Localizer["{0} is banned.", root.UserName]);
}
async Task Unban(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Lift {0}'s ban?", root.UserName], Localizer["Its personas are given back as they were."], Localizer["Unban"], false))
return;
await Run(root, ModerationService.Unban, Localizer["{0} is no longer banned.", root.UserName]);
}
async Task ResetTotp(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Turn off {0}'s two-factor sign-in?", root.UserName],
Localizer["For a login that lost its authenticator app and its recovery codes: its password and its sessions stay."],
Localizer["Reset two-factor"], true))
return;
await Run(root, ModerationService.ResetTotp, Localizer["{0} signs in with its password alone now.", root.UserName]);
}
async Task DeleteRoot(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Delete {0}?", root.UserName],
Localizer["Every persona of it goes with it, and everything public they had, here and on the servers that hold it. Its names stay taken. It cannot be undone."],
Localizer["Delete for good"], true))
return;
busy.Add(root.Id);
var result = await ModerationService.DeleteRoot(root.Id);
busy.Remove(root.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
roots = [.. roots.Where(r => r.Id != root.Id)];
Toast.AddResponseSuccess(Localizer["{0} is deleted.", root.UserName]);
}
async Task Run(ViewAdminRoot root, Func<string, CancellationToken, Task<WebResult>> change, string done)
{
busy.Add(root.Id);
var result = await change(root.Id, default);
busy.Remove(root.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await Refresh(root);
Toast.AddResponseSuccess(done);
}
string Named => actingGroup is { } group ? "@" + group.UserName
: actingPersona?.DisplayName is { Length: > 0 } displayName ? displayName
: "@" + actingPersona?.Handle;
string Question => act switch
{
ModerationActions.Silence => Localizer["Silence {0}?", Named],
ModerationActions.Unsilence => Localizer["Unsilence {0}?", Named],
ModerationActions.Suspend => Localizer["Suspend {0}?", Named],
ModerationActions.Unsuspend => Localizer["Unsuspend {0}?", Named],
PersonaDelete => Localizer["Delete {0}?", Named],
_ => Localizer["Delete the group {0}?", Named]
};
string ActText => act switch
{
ModerationActions.Silence => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."],
ModerationActions.Suspend => 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.Unsilence => Localizer["Its posts are listed again."],
ModerationActions.Unsuspend => Localizer["It is given back as it was, but the sessions a suspension ended."],
PersonaDelete when actingPersona?.Groups.Count > 0 => Localizer["Everything public it had goes, here and on the servers that hold it, and the {0} groups it owns with it. Its name stays taken. It cannot be undone.",
actingPersona.Groups.Count],
PersonaDelete => 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."],
_ => Localizer["Its members and followers are told, and its name stays taken. It cannot be undone."]
};
string ConfirmText => act switch
{
ModerationActions.Silence => Localizer["Silence"],
ModerationActions.Unsilence => Localizer["Unsilence"],
ModerationActions.Suspend => Localizer["Suspend"],
ModerationActions.Unsuspend => Localizer["Unsuspend"],
_ => Localizer["Delete for good"]
};
void Ask(ViewAdminRoot root, ViewAdminPersona persona, ViewAdminGroup group, string what) =>
(actingRoot, actingPersona, actingGroup, act) = (root, persona, group, what);
void CloseAct() => (actingRoot, actingPersona, actingGroup, act) = (null, null, null, null);
async Task<WebResult> Act(ActDialog.Answer answer)
{
var (root, persona, group, what) = (actingRoot, actingPersona, actingGroup, act);
var result = what switch
{
PersonaDelete => await ModerationService.DeletePersona(persona.Id, new AdminPasswordForm { Password = answer.Password, Note = answer.Note }),
GroupDelete => await ModerationService.DeleteGroup(group.Id, new AdminPasswordForm { Password = answer.Password, Note = answer.Note }),
_ => await ModerationService.Moderate(new ModerateAccountForm
{
AccountId = persona.Id,
Action = what,
Note = answer.Note,
Warning = what is ModerationActions.Silence or ModerationActions.Suspend ? answer.Warning : null
})
};
if (result.IsInvalid)
return result;
await Refresh(root);
// one of the administrator's own personas: the session follows it
if (root.IsYou && what == PersonaDelete)
await CascadingState.PersonaDeleted(persona.Id);
else if (root.IsYou)
await CascadingState.ReloadPersonas();
Toast.AddResponseSuccess(what switch
{
ModerationActions.Silence => Localizer["{0} is silenced.", Named],
ModerationActions.Unsilence => Localizer["{0} is no longer silenced.", Named],
ModerationActions.Suspend => Localizer["{0} is suspended.", Named],
ModerationActions.Unsuspend => Localizer["{0} is no longer suspended.", Named],
_ => Localizer["{0} is deleted.", Named]
});
return result;
}
}
+72 -3
View File
@@ -10,8 +10,10 @@
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line, and a
are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@ warning from the server's staff (moderation_warning) shows what they did and what they wrote. A moderator removes
another's post from its menu, with a note and a warning, asked first. Nerd stats are on the time, the author and its
server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut) @if (!IsFilteredOut)
{ {
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer" <article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -60,6 +62,20 @@
</DButton> </DButton>
} }
</p> </p>
@if (notification.ModerationWarning is { } warning)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs text-sm" data-notification-warning="@warning.Action">
<b>@WarningActionText(warning.Action)</b>
@if (warning.Text is { Length: > 0 } warningText)
{
<p class="whitespace-pre-line break-words" data-notification-warning-text>@warningText</p>
}
@if (warning.StatusIds.Count > 0)
{
<p class="text-xs opacity-70">@Localizer["It is about {0} of your posts.", warning.StatusIds.Count]</p>
}
</div>
}
} }
else if (Post.BoostedBy is { } booster) else if (Post.BoostedBy is { } booster)
{ {
@@ -434,6 +450,14 @@
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i> <i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span> <span>@Localizer["Block"]</span>
</DButton> </DButton>
@if (CascadingState.IsModerator && !Post.Located)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="@(() => isModeratingPost = true)" data-moderate-remove-post>
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Remove post (moderation)"]</span>
</DButton>
}
} }
else else
{ {
@@ -508,6 +532,14 @@
{ {
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" /> <ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
} }
@if (isModeratingPost)
{
<ActDialog Question="@Localizer["Remove this post as a moderator?"]"
Text="@Localizer["A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here."]"
ConfirmText="@Localizer["Remove the post"]" Danger="true" Act="remove-post" Staff="true" AskNote="true" AskWarning="true"
WarningHint="@Localizer["Only a persona here is told; an account elsewhere is told nothing."]" OnConfirm="RemoveAsModerator"
OnClosed="ModerationClosed" />
}
@if (showInteractedBy is { } boosts) @if (showInteractedBy is { } boosts)
{ {
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" /> <InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
@@ -544,7 +576,7 @@
} }
} }
} }
else if (Post.Notification?.Account is { } follower) else if (Post.Notification is { Type: not "moderation_warning", Account: { } follower })
{ {
<p class="text-sm"> <p class="text-sm">
<a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a> <a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a>
@@ -577,6 +609,7 @@
[Inject] TimelineService TimelineService { get; set; } [Inject] TimelineService TimelineService { get; set; }
[Inject] InstanceService InstanceService { get; set; } [Inject] InstanceService InstanceService { get; set; }
[Inject] ProvenanceService ProvenanceService { get; set; } [Inject] ProvenanceService ProvenanceService { get; set; }
[Inject] ModerationService ModerationService { get; set; }
[Parameter, EditorRequired] public FeedPost Post { get; set; } [Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; } [Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
@@ -610,6 +643,9 @@
bool isReporting; bool isReporting;
bool isFilteringPost; bool isFilteringPost;
bool isChoosingQuotePolicy; bool isChoosingQuotePolicy;
bool isModeratingPost;
// the staff removed the post: the page takes it out once the dialog closes
bool removedByModerator;
// who liked it (false) or boosted it (true), when that list is open // who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy; bool? showInteractedBy;
List<StatusEdit> history = []; List<StatusEdit> history = [];
@@ -1123,6 +1159,26 @@
await OnDeleted.InvokeAsync(Post); await OnDeleted.InvokeAsync(Post);
} }
// a moderator takes another's post down (/clientapi/moderator/posts/remove): deleted when it was written here, hidden
// here when it is a copy from elsewhere
async Task<WebResult> RemoveAsModerator(ActDialog.Answer answer)
{
var result = await ModerationService.RemovePost(new RemovePostForm { PostId = Status.Id, Note = answer.Note, Warning = answer.Warning });
if (result.IsInvalid)
return result;
removedByModerator = true;
Toast.AddResponseSuccess(Localizer["Post removed."]);
return result;
}
async Task ModerationClosed()
{
isModeratingPost = false;
if (removedByModerator)
await OnDeleted.InvokeAsync(Post);
}
// someone's post on the persona's wall: its owner deletes it there (PrivaPub tells the wall's readers, and Smithereen // someone's post on the persona's wall: its owner deletes it there (PrivaPub tells the wall's readers, and Smithereen
// deletes the post) // deletes the post)
bool IsOnOwnWall(Status status) => !IsOwn && status.Privapub?.Wall?.Id is { } owner && owner == CascadingState.PersonaId; bool IsOnOwnWall(Status status) => !IsOwn && status.Privapub?.Wall?.Id is { } owner && owner == CascadingState.PersonaId;
@@ -1293,9 +1349,21 @@
"poll" => "ion-md-podium", "poll" => "ion-md-podium",
"update" or "quoted_update" => "ion-md-create", "update" or "quoted_update" => "ion-md-create",
"quote" => "ion-md-quote", "quote" => "ion-md-quote",
"moderation_warning" => "ion-md-warning text-warning",
_ => "ion-md-notifications" _ => "ion-md-notifications"
}; };
// what a warning from the server's staff says they did
string WarningActionText(string action) => action switch
{
"silence" => Localizer["This persona is silenced: its posts stay out of public timelines, hashtags, trends and search."],
"suspend" => Localizer["This persona was suspended."],
"delete_statuses" => Localizer["A post of yours was removed."],
"mark_statuses_as_sensitive" or "sensitive" => Localizer["Your media were marked as sensitive."],
"disable" => Localizer["This login was disabled."],
_ => Localizer["A warning from this server's staff."]
};
string NotificationText(Notification notification) string NotificationText(Notification notification)
{ {
var name = notification.Account?.Name; var name = notification.Account?.Name;
@@ -1320,6 +1388,7 @@
"update" => Localizer["{0} edited a post", name], "update" => Localizer["{0} edited a post", name],
"quote" => Localizer["{0} quoted your post", name], "quote" => Localizer["{0} quoted your post", name],
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji], "pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
"moderation_warning" => Localizer["This server's staff warned you"],
_ => Localizer["{0}: {1}", name, notification.Type] _ => Localizer["{0}: {1}", name, notification.Type]
}; };
} }
+58 -4
View File
@@ -1,26 +1,58 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked @* The root's personas (/clientapi/avatar/private): each one used in turn, a new one made, and one deleted with the
to one another: no server, PrivaPub's included, shows that they share a root. *@ 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-4 w-full" data-personas>
<div class="flex flex-col gap-2"> <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) @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="" /> <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> <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) @if (persona.Id == CascadingState.PersonaId)
{ {
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge> <DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
} }
else 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>
} }
</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> <EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<b class="text-sm">@Localizer["A new persona"]</b> <b class="text-sm">@Localizer["A new persona"]</b>
@@ -47,6 +79,15 @@
InsertAvatarForm form = new(); InsertAvatarForm form = new();
bool isSaving; bool isSaving;
string switching; 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; protected override void OnInitialized() => IsLoading = false;
@@ -59,6 +100,19 @@
Toast.AddResponseError(result); 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() async Task Create()
{ {
isSaving = true; isSaving = true;
+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 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 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: 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> <CascadingValue IsFixed=false Value=this>
@ChildContent @ChildContent
</CascadingValue> </CascadingValue>
@@ -24,6 +26,7 @@
[Inject] AuthService AuthService { get; set; } [Inject] AuthService AuthService { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; } [Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] InstanceService InstanceService { get; set; } [Inject] InstanceService InstanceService { get; set; }
[Inject] CoalescingStringLocalizer Localizer { get; set; }
public bool IsOnline { get; private set; } = true; public bool IsOnline { get; private set; } = true;
public bool IsOffline => !IsOnline; public bool IsOffline => !IsOnline;
@@ -40,6 +43,10 @@
public string PersonaId { get; private set; } public string PersonaId { get; private set; }
/// <summary>That persona's Mastodon account, null until it is read.</summary> /// <summary>That persona's Mastodon account, null until it is read.</summary>
public Account Persona { get; private set; } 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; DotNetObjectReference<CascadingState> cascadingStateReference;
PeriodicTimer onlineTimer; PeriodicTimer onlineTimer;
@@ -50,6 +57,8 @@
(string, short, short, bool, bool, bool, bool)? loginSettings; (string, short, short, bool, bool, bool, bool)? loginSettings;
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary> /// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
CancellationTokenSource pendingSettingsSave; 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() protected override async Task OnInitializedAsync()
{ {
@@ -58,6 +67,7 @@
{ {
cascadingStateReference = DotNetObjectReference.Create(this); cascadingStateReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference); await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference);
AuthStateProvider.PersonaRefused += OnPersonaRefused;
var publicCacheData = await Storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken); var publicCacheData = await Storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken);
await UpdatePublicCache(publicCacheData ?? new(), cancellationToken); await UpdatePublicCache(publicCacheData ?? new(), cancellationToken);
@@ -197,10 +207,23 @@
var session = await AuthStateProvider.GetAuthData(cancellationToken); var session = await AuthStateProvider.GetAuthData(cancellationToken);
RootUserName = session.UserName; RootUserName = session.UserName;
IsModerator = session.Policies.Contains(Policies.IsModerator);
IsAdmin = session.Policies.Contains(Policies.IsAdmin);
Personas = await AuthService.Personas(cancellationToken); 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) 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; PersonaId = null;
Persona = null; Persona = null;
IsSessionLoaded = true; IsSessionLoaded = true;
@@ -215,9 +238,21 @@
StateHasChanged(); 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) 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); var result = await AuthService.UsePersona(personaId, cancellationToken);
if (result.IsInvalid) if (result.IsInvalid)
return result; return result;
@@ -237,6 +272,103 @@
StateHasChanged(); 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 /// <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> /// chooser shows the new name and picture too.</summary>
public void PersonaChanged(Account account) public void PersonaChanged(Account account)
@@ -268,6 +400,8 @@
loginSettings = null; loginSettings = null;
IsSignedIn = false; IsSignedIn = false;
RootUserName = null; RootUserName = null;
IsModerator = false;
IsAdmin = false;
Personas = []; Personas = [];
PersonaId = null; PersonaId = null;
Persona = null; Persona = null;
@@ -332,6 +466,7 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
AuthStateProvider.PersonaRefused -= OnPersonaRefused;
await disposeCancellation.CancelAsync(); await disposeCancellation.CancelAsync();
disposeCancellation.Dispose(); disposeCancellation.Dispose();
CancelSettingsSave(); CancelSettingsSave();
+1
View File
@@ -24,6 +24,7 @@ namespace decePubClient;
[JsonSerializable(typeof(InstanceV2))] [JsonSerializable(typeof(InstanceV2))]
[JsonSerializable(typeof(Notification))] [JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(List<Notification>))] [JsonSerializable(typeof(List<Notification>))]
[JsonSerializable(typeof(AccountWarning))]
[JsonSerializable(typeof(Conversation))] [JsonSerializable(typeof(Conversation))]
[JsonSerializable(typeof(List<Conversation>))] [JsonSerializable(typeof(List<Conversation>))]
[JsonSerializable(typeof(InstanceDescription))] [JsonSerializable(typeof(InstanceDescription))]
+16
View File
@@ -35,6 +35,7 @@ public static class APIs
public const string POST_DeleteAccount = "clientapi/user/delete"; public const string POST_DeleteAccount = "clientapi/user/delete";
public const string GET_Personas = "clientapi/avatar/private/list"; public const string GET_Personas = "clientapi/avatar/private/list";
public const string POST_InsertPersona = "clientapi/avatar/private/insert"; public const string POST_InsertPersona = "clientapi/avatar/private/insert";
public const string POST_DeletePersona = "clientapi/avatar/private/delete";
public const string GET_Groups = "clientapi/group/list"; public const string GET_Groups = "clientapi/group/list";
public const string GET_GroupMembers = "clientapi/group/members"; public const string GET_GroupMembers = "clientapi/group/members";
public const string POST_InsertGroup = "clientapi/group/insert"; public const string POST_InsertGroup = "clientapi/group/insert";
@@ -44,6 +45,7 @@ public static class APIs
public const string POST_ApproveGroupMember = "clientapi/group/approve"; public const string POST_ApproveGroupMember = "clientapi/group/approve";
public const string POST_RejectGroupMember = "clientapi/group/reject"; public const string POST_RejectGroupMember = "clientapi/group/reject";
public const string POST_RemoveGroupMember = "clientapi/group/remove"; public const string POST_RemoveGroupMember = "clientapi/group/remove";
public const string POST_DeleteGroup = "clientapi/group/delete";
public const string GET_Posts = "clientapi/post/list"; public const string GET_Posts = "clientapi/post/list";
public const string GET_Nearby = "clientapi/post/nearby"; public const string GET_Nearby = "clientapi/post/nearby";
public const string POST_InsertPost = "clientapi/post/insert"; public const string POST_InsertPost = "clientapi/post/insert";
@@ -51,6 +53,20 @@ public static class APIs
public const string GET_ModeratorReports = "clientapi/moderator/reports"; public const string GET_ModeratorReports = "clientapi/moderator/reports";
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve"; public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
public const string GET_ModeratorLog = "clientapi/moderator/log"; public const string GET_ModeratorLog = "clientapi/moderator/log";
public const string GET_ModeratorAccounts = "clientapi/moderator/accounts";
public const string GET_ModeratedAccounts = "clientapi/moderator/accounts/moderated";
public static string GET_M_ModeratorAccount(string accountId) => $"clientapi/moderator/accounts/{Uri.EscapeDataString(accountId)}";
public const string POST_ModerateAccount = "clientapi/moderator/accounts/moderate";
public const string POST_ModeratorRemovePost = "clientapi/moderator/posts/remove";
public const string GET_AdminRoots = "clientapi/admin/roots";
public static string GET_M_AdminRoot(string rootId) => $"clientapi/admin/roots/{Uri.EscapeDataString(rootId)}";
public static string POST_M_AdminRootRole(string rootId) => $"clientapi/admin/roots/{Uri.EscapeDataString(rootId)}/role";
public static string POST_M_AdminRootTotpReset(string rootId) => $"clientapi/admin/roots/{Uri.EscapeDataString(rootId)}/totp/reset";
public static string POST_M_AdminPersonaDelete(string personaId) => $"clientapi/admin/personas/{Uri.EscapeDataString(personaId)}/delete";
public static string POST_M_AdminGroupDelete(string groupId) => $"clientapi/admin/groups/{Uri.EscapeDataString(groupId)}/delete";
public const string POST_AdminBanRoots = "clientapi/admin/ban/users";
public const string POST_AdminUnbanRoots = "clientapi/admin/unban/users";
public const string DELETE_AdminRemoveRoots = "clientapi/admin/remove/users";
public const string GET_AdminSettings = "clientapi/admin/settings"; public const string GET_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminSettings = "clientapi/admin/settings"; public const string POST_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail"; public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail";
+3
View File
@@ -14,6 +14,9 @@ public static class FailureCodes
public const string ServerError = "SERVER-ERROR"; public const string ServerError = "SERVER-ERROR";
public const string ClientError = "CLIENT-ERROR"; public const string ClientError = "CLIENT-ERROR";
public const string Unauthorized = "UNAUTHORIZED"; public const string Unauthorized = "UNAUTHORIZED";
/// <summary>PrivaPub refused the current persona a token while the login's own session is good: the staff suspended it,
/// or it is gone.</summary>
public const string PersonaRefused = "PERSONA-REFUSED";
public static bool IsConnectivity(string errorCode) => public static bool IsConnectivity(string errorCode) =>
errorCode is DeviceOffline or Timeout or ApiUnreachable or RetriesExhausted; errorCode is DeviceOffline or Timeout or ApiUnreachable or RetriesExhausted;
+2 -1
View File
@@ -72,7 +72,8 @@ public class FeedPost
Account = sample.FirstOrDefault(), Account = sample.FirstOrDefault(),
Status = status, Status = status,
Emoji = group.Emoji, Emoji = group.Emoji,
GroupKey = group.GroupKey GroupKey = group.GroupKey,
ModerationWarning = group.ModerationWarning
}, },
GroupCount = Math.Max(1, group.NotificationsCount), GroupCount = Math.Max(1, group.NotificationsCount),
GroupAccounts = sample GroupAccounts = sample
+3
View File
@@ -31,6 +31,9 @@ public class Account
public bool? Discoverable { get; set; } public bool? Discoverable { get; set; }
/// <summary>Whether its public posts may be found by full-text search.</summary> /// <summary>Whether its public posts may be found by full-text search.</summary>
public bool? Indexable { get; set; } public bool? Indexable { get; set; }
/// <summary>Silenced by this server's staff, or its server is (Mastodon's `limited`): kept out of public timelines,
/// trends and search, still read by its followers and by whoever opens it. Null otherwise.</summary>
public bool? Limited { get; set; }
/// <summary>The persona's own account only (verify_credentials, update_credentials): its profile as written and its /// <summary>The persona's own account only (verify_credentials, update_credentials): its profile as written and its
/// posting defaults.</summary> /// posting defaults.</summary>
public AccountSource Source { get; set; } public AccountSource Source { get; set; }
+20 -2
View File
@@ -1,8 +1,8 @@
namespace decePubClient.Models.Mastodon; namespace decePubClient.Models.Mastodon;
/// <summary> /// <summary>
/// type: mention, status, reblog, follow, follow_request, favourite, poll, update, quote, quoted_update or /// type: mention, status, reblog, follow, follow_request, favourite, poll, update, quote, quoted_update,
/// pleroma:emoji_reaction. Status is null for follows. /// pleroma:emoji_reaction or moderation_warning. Status is null for follows and for most warnings (a removed post is gone).
/// </summary> /// </summary>
public class Notification public class Notification
{ {
@@ -14,6 +14,22 @@ public class Notification
public string Emoji { get; set; } public string Emoji { get; set; }
/// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary> /// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary>
public string GroupKey { get; set; } public string GroupKey { get; set; }
/// <summary>A moderation_warning's: what this server's staff did and told the persona. Its Account is the persona
/// itself, as Mastodon's are.</summary>
public AccountWarning ModerationWarning { get; set; }
}
/// <summary>Mastodon's AccountWarning: what this server's staff did to the persona and why, as they wrote it.</summary>
public class AccountWarning
{
public string Id { get; set; }
/// <summary>none, silence, suspend or delete_statuses (PrivaPub's); Mastodon also has disable, mark_statuses_as_sensitive
/// and sensitive.</summary>
public string Action { get; set; }
public string Text { get; set; }
/// <summary>The posts it is about (a removed post's id stays named).</summary>
public List<string> StatusIds { get; set; } = [];
public DateTime? CreatedAt { get; set; }
} }
/// <summary>/api/v2/notifications: notifications folded into groups, with the accounts and posts they name.</summary> /// <summary>/api/v2/notifications: notifications folded into groups, with the accounts and posts they name.</summary>
@@ -36,6 +52,8 @@ public class NotificationGroup
public List<string> SampleAccountIds { get; set; } = []; public List<string> SampleAccountIds { get; set; } = [];
public string StatusId { get; set; } public string StatusId { get; set; }
public string Emoji { get; set; } public string Emoji { get; set; }
/// <summary>A moderation_warning group's newest warning.</summary>
public AccountWarning ModerationWarning { get; set; }
} }
public class MarkersForm public class MarkersForm
+31 -4
View File
@@ -4,10 +4,11 @@
<Title>@Localizer["Administration"]</Title> <Title>@Localizer["Administration"]</Title>
@* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its @* Moderators handle reports, moderate accounts here and elsewhere (Accounts) and read the staff's log. Administrators
rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation also change the logins and their personas (Users & personas), say what the server is (Instance), set its rules, write
statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. its announcements, block servers for everyone, choose its relays and media limits, read the federation statistics, and
Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@ back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. Each section is
a collapse marked data-admin-section; its panel loads when the page opens. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto"> <div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -25,6 +26,19 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="accounts">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-contacts text-xl"></i> <span>@Localizer["Accounts"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<AccountsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log"> <DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
@@ -39,6 +53,19 @@
</DCollapse> </DCollapse>
<AuthorizeView Policy="@Policies.IsAdmin"> <AuthorizeView Policy="@Policies.IsAdmin">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="users">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-people text-xl"></i> <span>@Localizer["Users & personas"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<UsersPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance"> <DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
+36 -1
View File
@@ -4,7 +4,8 @@
<Title>@Localizer["Groups"]</Title> <Title>@Localizer["Groups"]</Title>
@* The current persona's communities and circles (/clientapi/group): made, joined by invitation, and left; for the ones @* The current persona's communities and circles (/clientapi/group): made, joined by invitation, and left; for the ones
it runs, edited, their invitation shown, the requests to join answered and members taken out. A circle's members are it runs, edited, their invitation shown, the requests to join answered and members taken out; for the ones it owns,
deleted with the login's password, asked first (its members are told, its name stays taken). A circle's members are
never shown to anyone but its owner and moderators, here as on every server. *@ never shown to anyone but its owner and moderators, here as on every server. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto"> <div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -179,6 +180,13 @@
</div> </div>
} }
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2 pt-2 border-t border-base-300">
<span class="text-xs opacity-70 flex-1 min-w-48">@Localizer["Deleting the group tells its members, and its name stays taken."]</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => deleting = group" data-group-delete-own>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete the group"]</span>
</DButton>
</div>
} }
</article> </article>
} }
@@ -186,6 +194,14 @@
</div> </div>
</section> </section>
@if (deleting is not null)
{
<ActDialog Question="@Localizer["Delete the group {0}?", deleting.Name ?? deleting.UserName]"
Text="@Localizer["Its members and followers are told, and its name stays taken. It cannot be undone."]"
ConfirmText="@Localizer["Delete for good"]" Danger="true" Act="group-delete" AskPassword="true" OnConfirm="Delete"
OnClosed="() => deleting = null" />
}
@code { @code {
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"]; static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
@@ -200,6 +216,8 @@
bool editDiscoverable; bool editDiscoverable;
bool editApproves; bool editApproves;
ViewGroup open; ViewGroup open;
// the group whose deletion the dialog asks about
ViewGroup deleting;
string loadedPersonaId; string loadedPersonaId;
bool isSaving; bool isSaving;
@@ -300,6 +318,23 @@
Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]); Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]);
} }
async Task<WebResult> Delete(ActDialog.Answer answer)
{
var group = deleting;
var result = await GroupService.Delete(CascadingState.PersonaId, group.Id, answer.Password);
if (result.IsInvalid)
return result;
groups = [.. groups.Where(g => g.Id != group.Id)];
if (open?.Id == group.Id)
{
open = null;
members = [];
}
Toast.AddResponseSuccess(Localizer["{0} is deleted.", group.Name ?? group.UserName]);
return result;
}
async Task Toggle(ViewGroup group) async Task Toggle(ViewGroup group)
{ {
if (open?.Id == group.Id) if (open?.Id == group.Id)
+15 -2
View File
@@ -114,14 +114,27 @@
<div class="flex flex-col gap-4 w-full max-w-md mt-6 md:mt-16"> <div class="flex flex-col gap-4 w-full max-w-md mt-6 md:mt-16">
<h1 class="text-xl font-bold text-center">@Localizer["Who are you today?"]</h1> <h1 class="text-xl font-bold text-center">@Localizer["Who are you today?"]</h1>
<p class="text-sm opacity-70 text-center">@Localizer["Each persona has its own token; PrivaPub never tells anyone they share a login."]</p> <p class="text-sm opacity-70 text-center">@Localizer["Each persona has its own token; PrivaPub never tells anyone they share a login."]</p>
@if (CascadingState.Personas.All(p => !CascadingState.CanUse(p)))
{
<p class="text-sm text-center" data-personas-suspended>
@Localizer["Every persona of this login is suspended by this server's staff. Settings can still make a new one."]
</p>
<DButton Href="settings">@Localizer["Settings"]</DButton>
}
@foreach (var persona in CascadingState.Personas) @foreach (var persona in CascadingState.Personas)
{ {
<button type="button" class="flex items-center gap-3 p-3 rounded-xl text-left neo-raised neo-xs hover:neo-sm transition-shadow @(persona.Id == CascadingState.PersonaId ? "ring-2 ring-primary" : null)" @* a persona the staff suspended can't be chosen until that is lifted *@
disabled="@(IsDefaultDisabled || isWorking)" @onclick="() => Choose(persona.Id)"> <button type="button" class="flex items-center gap-3 p-3 rounded-xl text-left neo-raised neo-xs hover:neo-sm transition-shadow disabled:opacity-60 @(persona.Id == CascadingState.PersonaId ? "ring-2 ring-primary" : null)"
disabled="@(IsDefaultDisabled || isWorking || !CascadingState.CanUse(persona))" @onclick="() => Choose(persona.Id)"
data-persona-choice="@persona.UserName" data-persona-state="@(CascadingState.CanUse(persona) ? "normal" : "suspended")">
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full neo-raised neo-2xs" src="@(persona.PictureURL ?? "imgs/icon-192.png")" /> <img alt="" class="h-12 w-12 flex-none object-cover rounded-full neo-raised neo-2xs" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0"> <span class="flex flex-col min-w-0">
<b class="truncate">@(persona.Name ?? persona.UserName)</b> <b class="truncate">@(persona.Name ?? persona.UserName)</b>
<span class="text-xs opacity-70 truncate">@(persona.Handle ?? "@" + persona.UserName)</span> <span class="text-xs opacity-70 truncate">@(persona.Handle ?? "@" + persona.UserName)</span>
@if (!CascadingState.CanUse(persona))
{
<span class="text-xs text-error">@Localizer["Suspended by this server's staff: it can't act until that is lifted."]</span>
}
</span> </span>
@if (persona.Id == CascadingState.PersonaId) @if (persona.Id == CascadingState.PersonaId)
{ {
+7
View File
@@ -126,6 +126,13 @@
{ {
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-people"></i> @Localizer["Groups"]</DBadge> <DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-people"></i> @Localizer["Groups"]</DBadge>
} }
@if (account.Limited == true)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-limited
title="@Localizer["This server's staff limited it: its posts stay out of public timelines, trends and search."]">
<i class="ion-md-volume-off"></i> @Localizer["Limited"]
</DBadge>
}
</div> </div>
@if (!string.IsNullOrWhiteSpace(account.Note)) @if (!string.IsNullOrWhiteSpace(account.Note))
+1
View File
@@ -45,6 +45,7 @@ builder.Services.AddOptions()
.AddScoped<PortabilityService>() .AddScoped<PortabilityService>()
.AddScoped<AdminService>() .AddScoped<AdminService>()
.AddScoped<InstanceAdminService>() .AddScoped<InstanceAdminService>()
.AddScoped<ModerationService>()
.AddScoped<AboutService>() .AddScoped<AboutService>()
.AddScoped<AnnouncementService>() .AddScoped<AnnouncementService>()
.AddScoped<ArchiveService>() .AddScoped<ArchiveService>()
+396
View File
@@ -3086,4 +3086,400 @@
<data name="the command line" xml:space="preserve"> <data name="the command line" xml:space="preserve">
<value>la riga di comando</value> <value>la riga di comando</value>
</data> </data>
<data name="A note for the staff (optional)" xml:space="preserve">
<value>Una nota per lo staff (facoltativa)</value>
</data>
<data name="Only the staff read it, in the moderation log." xml:space="preserve">
<value>La legge solo lo staff, nel registro di moderazione.</value>
</data>
<data name="What the persona is told (optional)" xml:space="preserve">
<value>Cosa viene detto alla persona (facoltativo)</value>
</data>
<data name="It reaches the persona as a warning from this server's staff." xml:space="preserve">
<value>Arriva alla persona come avviso dello staff di questo server.</value>
</data>
<data name="Your password" xml:space="preserve">
<value>La tua password</value>
</data>
<data name="A name, user@host or an address" xml:space="preserve">
<value>Un nome, utente@host o un indirizzo</value>
</data>
<data name="Search accounts" xml:space="preserve">
<value>Cerca account</value>
</data>
<data name="No account found." xml:space="preserve">
<value>Nessun account trovato.</value>
</data>
<data name="Silenced and suspended" xml:space="preserve">
<value>Silenziati e sospesi</value>
</data>
<data name="The accounts could not be read." xml:space="preserve">
<value>Non è stato possibile leggere gli account.</value>
</data>
<data name="No account is silenced or suspended." xml:space="preserve">
<value>Nessun account è silenziato o sospeso.</value>
</data>
<data name="here" xml:space="preserve">
<value>qui</value>
</data>
<data name="its server is silenced" xml:space="preserve">
<value>il suo server è silenziato</value>
</data>
<data name="its server is suspended" xml:space="preserve">
<value>il suo server è sospeso</value>
</data>
<data name="Staff note" xml:space="preserve">
<value>Nota dello staff</value>
</data>
<data name="Silence" xml:space="preserve">
<value>Silenzia</value>
</data>
<data name="Unsilence" xml:space="preserve">
<value>Togli il silenzio</value>
</data>
<data name="Suspend" xml:space="preserve">
<value>Sospendi</value>
</data>
<data name="Unsuspend" xml:space="preserve">
<value>Revoca la sospensione</value>
</data>
<data name="Silence {0}?" xml:space="preserve">
<value>Silenziare {0}?</value>
</data>
<data name="Unsilence {0}?" xml:space="preserve">
<value>Togliere il silenzio a {0}?</value>
</data>
<data name="Suspend {0}?" xml:space="preserve">
<value>Sospendere {0}?</value>
</data>
<data name="Unsuspend {0}?" xml:space="preserve">
<value>Revocare la sospensione di {0}?</value>
</data>
<data name="Its posts stay out of public timelines, hashtags, trends and search; its followers still read them." xml:space="preserve">
<value>I suoi post restano fuori dalle timeline pubbliche, dagli hashtag, dalle tendenze e dalla ricerca; i suoi follower continuano a leggerli.</value>
</data>
<data name="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." xml:space="preserve">
<value>La persona non può più agire né essere vista qui, e le sue sessioni finiscono; agli altri server non viene detto nulla di nuovo. Leggerà l'avviso quando la sospensione sarà revocata.</value>
</data>
<data name="Nothing of it is shown here any more, what it sends is dropped and nothing is delivered to it. Its server is told nothing." xml:space="preserve">
<value>Qui non se ne mostra più nulla, ciò che invia viene scartato e non gli viene consegnato nulla. Al suo server non viene detto nulla.</value>
</data>
<data name="Its posts are listed again." xml:space="preserve">
<value>I suoi post tornano a essere elencati.</value>
</data>
<data name="It is given back as it was, but the sessions a suspension ended." xml:space="preserve">
<value>Torna com'era, tranne le sessioni che la sospensione ha chiuso.</value>
</data>
<data name="{0} is silenced." xml:space="preserve">
<value>{0} è silenziato.</value>
</data>
<data name="{0} is no longer silenced." xml:space="preserve">
<value>{0} non è più silenziato.</value>
</data>
<data name="{0} is suspended." xml:space="preserve">
<value>{0} è sospeso.</value>
</data>
<data name="{0} is no longer suspended." xml:space="preserve">
<value>{0} non è più sospeso.</value>
</data>
<data name="A login's name, or one of its personas'" xml:space="preserve">
<value>Il nome di un accesso, o di una delle sue persone</value>
</data>
<data name="Search logins" xml:space="preserve">
<value>Cerca accessi</value>
</data>
<data name="The logins could not be read." xml:space="preserve">
<value>Non è stato possibile leggere gli accessi.</value>
</data>
<data name="No login found." xml:space="preserve">
<value>Nessun accesso trovato.</value>
</data>
<data name="you" xml:space="preserve">
<value>tu</value>
</data>
<data name="banned" xml:space="preserve">
<value>bandito</value>
</data>
<data name="two-factor" xml:space="preserve">
<value>due fattori</value>
</data>
<data name="no email address" xml:space="preserve">
<value>nessun indirizzo email</value>
</data>
<data name="email verified" xml:space="preserve">
<value>email verificata</value>
</data>
<data name="email not verified" xml:space="preserve">
<value>email non verificata</value>
</data>
<data name="Personas: {0}" xml:space="preserve">
<value>Persone: {0}</value>
</data>
<data name="Role" xml:space="preserve">
<value>Ruolo</value>
</data>
<data name="Unban" xml:space="preserve">
<value>Togli il bando</value>
</data>
<data name="Ban" xml:space="preserve">
<value>Bandisci</value>
</data>
<data name="Reset two-factor" xml:space="preserve">
<value>Azzera i due fattori</value>
</data>
<data name="Delete the login" xml:space="preserve">
<value>Elimina l'accesso</value>
</data>
<data name="This login has no persona." xml:space="preserve">
<value>Questo accesso non ha persone.</value>
</data>
<data name="Delete the group" xml:space="preserve">
<value>Elimina il gruppo</value>
</data>
<data name="administrator" xml:space="preserve">
<value>amministratore</value>
</data>
<data name="moderator" xml:space="preserve">
<value>moderatore</value>
</data>
<data name="user" xml:space="preserve">
<value>utente</value>
</data>
<data name="Make {0} an administrator?" xml:space="preserve">
<value>Rendere {0} amministratore?</value>
</data>
<data name="Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups." xml:space="preserve">
<value>Gli amministratori fanno tutto ciò che fanno i moderatori, e cambiano gli accessi, le impostazioni del server, i suoi relay e i suoi backup.</value>
</data>
<data name="Make {0} a moderator?" xml:space="preserve">
<value>Rendere {0} moderatore?</value>
</data>
<data name="Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings." xml:space="preserve">
<value>I moderatori gestiscono le segnalazioni, silenziano e sospendono account e rimuovono post; mai gli accessi né le impostazioni del server.</value>
</data>
<data name="Make {0} a user?" xml:space="preserve">
<value>Rendere {0} utente?</value>
</data>
<data name="It keeps its personas, and loses what the staff do." xml:space="preserve">
<value>Mantiene le sue persone e perde ciò che fa lo staff.</value>
</data>
<data name="Change the role" xml:space="preserve">
<value>Cambia il ruolo</value>
</data>
<data name="{0} is now {1}." xml:space="preserve">
<value>{0} ora è {1}.</value>
</data>
<data name="Ban {0}?" xml:space="preserve">
<value>Bandire {0}?</value>
</data>
<data name="Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted." xml:space="preserve">
<value>Le sue sessioni finiscono e le sue persone vengono trattenute come sospese, senza scrivere nulla su nessuna di esse. Il bando si può togliere.</value>
</data>
<data name="{0} is banned." xml:space="preserve">
<value>{0} è bandito.</value>
</data>
<data name="Lift {0}'s ban?" xml:space="preserve">
<value>Togliere il bando a {0}?</value>
</data>
<data name="Its personas are given back as they were." xml:space="preserve">
<value>Le sue persone tornano com'erano.</value>
</data>
<data name="{0} is no longer banned." xml:space="preserve">
<value>{0} non è più bandito.</value>
</data>
<data name="Turn off {0}'s two-factor sign-in?" xml:space="preserve">
<value>Disattivare l'accesso a due fattori di {0}?</value>
</data>
<data name="For a login that lost its authenticator app and its recovery codes: its password and its sessions stay." xml:space="preserve">
<value>Per un accesso che ha perso l'app di autenticazione e i codici di recupero: la password e le sessioni restano.</value>
</data>
<data name="{0} signs in with its password alone now." xml:space="preserve">
<value>Ora {0} accede con la sola password.</value>
</data>
<data name="Delete {0}?" xml:space="preserve">
<value>Eliminare {0}?</value>
</data>
<data name="Every persona of it goes with it, and everything public they had, here and on the servers that hold it. Its names stay taken. It cannot be undone." xml:space="preserve">
<value>Con esso se ne vanno tutte le sue persone e tutto ciò che avevano di pubblico, qui e sui server che lo conservano. I suoi nomi restano occupati. Non si può annullare.</value>
</data>
<data name="{0} is deleted." xml:space="preserve">
<value>{0} è stato eliminato.</value>
</data>
<data name="Delete the group {0}?" xml:space="preserve">
<value>Eliminare il gruppo {0}?</value>
</data>
<data name="Everything public it had goes, here and on the servers that hold it, and the {0} groups it owns with it. Its name stays taken. It cannot be undone." xml:space="preserve">
<value>Se ne va tutto ciò che aveva di pubblico, qui e sui server che lo conservano, e con essa i {0} gruppi che possiede. Il suo nome resta occupato. Non si può annullare.</value>
</data>
<data name="Its members and followers are told, and its name stays taken. It cannot be undone." xml:space="preserve">
<value>I suoi membri e follower vengono avvisati, e il suo nome resta occupato. Non si può annullare.</value>
</data>
<data name="Resolve and…" xml:space="preserve">
<value>Risolvi e…</value>
</data>
<data name="Resolve only" xml:space="preserve">
<value>Risolvi soltanto</value>
</data>
<data name="Silence the account" xml:space="preserve">
<value>Silenzia l'account</value>
</data>
<data name="Suspend the account" xml:space="preserve">
<value>Sospendi l'account</value>
</data>
<data name="Remove the reported posts" xml:space="preserve">
<value>Rimuovi i post segnalati</value>
</data>
<data name="Only a persona here is told; an account elsewhere is told nothing." xml:space="preserve">
<value>Viene avvisata solo una persona di qui; a un account altrove non viene detto nulla.</value>
</data>
<data name="The account was silenced." xml:space="preserve">
<value>L'account è stato silenziato.</value>
</data>
<data name="The account was suspended." xml:space="preserve">
<value>L'account è stato sospeso.</value>
</data>
<data name="The reported posts were removed." xml:space="preserve">
<value>I post segnalati sono stati rimossi.</value>
</data>
<data name="Resolved without an act." xml:space="preserve">
<value>Risolta senza alcun intervento.</value>
</data>
<data name="Resolve it and silence the account?" xml:space="preserve">
<value>Risolverla e silenziare l'account?</value>
</data>
<data name="Resolve it and suspend the account?" xml:space="preserve">
<value>Risolverla e sospendere l'account?</value>
</data>
<data name="Resolve it and remove the reported posts?" xml:space="preserve">
<value>Risolverla e rimuovere i post segnalati?</value>
</data>
<data name="Resolve the report?" xml:space="preserve">
<value>Risolvere la segnalazione?</value>
</data>
<data name="Nothing of it is shown here any more and it can't act; nothing new is told to other servers. It can be lifted later." xml:space="preserve">
<value>Qui non se ne mostra più nulla e non può agire; agli altri server non viene detto nulla di nuovo. Si può revocare in seguito.</value>
</data>
<data name="A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here." xml:space="preserve">
<value>Un post scritto qui viene eliminato come lo eliminerebbe il suo autore; la copia di uno di altrove viene nascosta qui.</value>
</data>
<data name="Nothing is done to the account or its posts." xml:space="preserve">
<value>Non si fa nulla all'account né ai suoi post.</value>
</data>
<data name="Remove the posts" xml:space="preserve">
<value>Rimuovi i post</value>
</data>
<data name="Login's role changed" xml:space="preserve">
<value>Ruolo dell'accesso cambiato</value>
</data>
<data name="Login banned" xml:space="preserve">
<value>Accesso bandito</value>
</data>
<data name="Login's ban lifted" xml:space="preserve">
<value>Bando dell'accesso tolto</value>
</data>
<data name="Account silenced" xml:space="preserve">
<value>Account silenziato</value>
</data>
<data name="Account no longer silenced" xml:space="preserve">
<value>Account non più silenziato</value>
</data>
<data name="Account suspended" xml:space="preserve">
<value>Account sospeso</value>
</data>
<data name="Account no longer suspended" xml:space="preserve">
<value>Account non più sospeso</value>
</data>
<data name="Persona deleted" xml:space="preserve">
<value>Persona eliminata</value>
</data>
<data name="Group deleted" xml:space="preserve">
<value>Gruppo eliminato</value>
</data>
<data name="Post removed" xml:space="preserve">
<value>Post rimosso</value>
</data>
<data name="This login has no persona to act as yet: make one below." xml:space="preserve">
<value>Questo accesso non ha ancora una persona con cui agire: creane una qui sotto.</value>
</data>
<data name="Suspended by this server's staff: it can't act until that is lifted." xml:space="preserve">
<value>Sospesa dallo staff di questo server: non può agire finché la sospensione non viene revocata.</value>
</data>
<data name="Silenced by this server's staff: its posts stay out of public timelines, trends and search." xml:space="preserve">
<value>Silenziata dallo staff di questo server: i suoi post restano fuori dalle timeline pubbliche, dalle tendenze e dalla ricerca.</value>
</data>
<data name="Delete this persona" xml:space="preserve">
<value>Elimina questa persona</value>
</data>
<data name="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." xml:space="preserve">
<value>Se ne va tutto ciò che aveva di pubblico, qui e sui server che lo conservano, con i gruppi che possiede. Il suo nome resta occupato. Non si può annullare.</value>
</data>
<data name="Deleting the group tells its members, and its name stays taken." xml:space="preserve">
<value>Eliminare il gruppo lo comunica ai suoi membri, e il suo nome resta occupato.</value>
</data>
<data name="Every persona of this login is suspended by this server's staff. Settings can still make a new one." xml:space="preserve">
<value>Tutte le persone di questo accesso sono sospese dallo staff di questo server. Dalle impostazioni se ne può comunque creare una nuova.</value>
</data>
<data name="It is about {0} of your posts." xml:space="preserve">
<value>Riguarda {0} dei tuoi post.</value>
</data>
<data name="Remove post (moderation)" xml:space="preserve">
<value>Rimuovi il post (moderazione)</value>
</data>
<data name="Remove this post as a moderator?" xml:space="preserve">
<value>Rimuovere questo post come moderatore?</value>
</data>
<data name="Remove the post" xml:space="preserve">
<value>Rimuovi il post</value>
</data>
<data name="Post removed." xml:space="preserve">
<value>Post rimosso.</value>
</data>
<data name="This persona is silenced: its posts stay out of public timelines, hashtags, trends and search." xml:space="preserve">
<value>Questa persona è silenziata: i suoi post restano fuori dalle timeline pubbliche, dagli hashtag, dalle tendenze e dalla ricerca.</value>
</data>
<data name="This persona was suspended." xml:space="preserve">
<value>Questa persona è stata sospesa.</value>
</data>
<data name="A post of yours was removed." xml:space="preserve">
<value>Un tuo post è stato rimosso.</value>
</data>
<data name="Your media were marked as sensitive." xml:space="preserve">
<value>I tuoi media sono stati segnati come sensibili.</value>
</data>
<data name="This login was disabled." xml:space="preserve">
<value>Questo accesso è stato disattivato.</value>
</data>
<data name="A warning from this server's staff." xml:space="preserve">
<value>Un avviso dello staff di questo server.</value>
</data>
<data name="This server's staff warned you" xml:space="preserve">
<value>Lo staff di questo server ti ha avvisato</value>
</data>
<data name="This server's staff limited it: its posts stay out of public timelines, trends and search." xml:space="preserve">
<value>Lo staff di questo server lo ha limitato: i suoi post restano fuori dalle timeline pubbliche, dalle tendenze e dalla ricerca.</value>
</data>
<data name="Users &amp; personas" xml:space="preserve">
<value>Utenti e persone</value>
</data>
<data name="{0} is suspended by this server's staff, and no other persona of this login can act now." xml:space="preserve">
<value>{0} è sospesa dallo staff di questo server, e nessun'altra persona di questo accesso può agire ora.</value>
</data>
<data name="{0} is suspended by this server's staff: you now act as {1}." xml:space="preserve">
<value>{0} è sospesa dallo staff di questo server: ora agisci come {1}.</value>
</data>
<data name="{0} is suspended by this server's staff." xml:space="preserve">
<value>{0} è sospesa dallo staff di questo server.</value>
</data>
<data name="{0} can no longer be used, and no other persona of this login can act now." xml:space="preserve">
<value>{0} non si può più usare, e nessun'altra persona di questo accesso può agire ora.</value>
</data>
<data name="{0} can no longer be used: you now act as {1}." xml:space="preserve">
<value>{0} non si può più usare: ora agisci come {1}.</value>
</data>
<data name="This persona can't act now: the server's staff may have suspended it." xml:space="preserve">
<value>Questa persona ora non può agire: lo staff del server potrebbe averla sospesa.</value>
</data>
<data name="Delete for good" xml:space="preserve">
<value>Elimina per sempre</value>
</data>
</root> </root>
+396
View File
@@ -3086,4 +3086,400 @@
<data name="the command line" xml:space="preserve"> <data name="the command line" xml:space="preserve">
<value>the command line</value> <value>the command line</value>
</data> </data>
<data name="A note for the staff (optional)" xml:space="preserve">
<value>A note for the staff (optional)</value>
</data>
<data name="Only the staff read it, in the moderation log." xml:space="preserve">
<value>Only the staff read it, in the moderation log.</value>
</data>
<data name="What the persona is told (optional)" xml:space="preserve">
<value>What the persona is told (optional)</value>
</data>
<data name="It reaches the persona as a warning from this server's staff." xml:space="preserve">
<value>It reaches the persona as a warning from this server's staff.</value>
</data>
<data name="Your password" xml:space="preserve">
<value>Your password</value>
</data>
<data name="A name, user@host or an address" xml:space="preserve">
<value>A name, user@host or an address</value>
</data>
<data name="Search accounts" xml:space="preserve">
<value>Search accounts</value>
</data>
<data name="No account found." xml:space="preserve">
<value>No account found.</value>
</data>
<data name="Silenced and suspended" xml:space="preserve">
<value>Silenced and suspended</value>
</data>
<data name="The accounts could not be read." xml:space="preserve">
<value>The accounts could not be read.</value>
</data>
<data name="No account is silenced or suspended." xml:space="preserve">
<value>No account is silenced or suspended.</value>
</data>
<data name="here" xml:space="preserve">
<value>here</value>
</data>
<data name="its server is silenced" xml:space="preserve">
<value>its server is silenced</value>
</data>
<data name="its server is suspended" xml:space="preserve">
<value>its server is suspended</value>
</data>
<data name="Staff note" xml:space="preserve">
<value>Staff note</value>
</data>
<data name="Silence" xml:space="preserve">
<value>Silence</value>
</data>
<data name="Unsilence" xml:space="preserve">
<value>Unsilence</value>
</data>
<data name="Suspend" xml:space="preserve">
<value>Suspend</value>
</data>
<data name="Unsuspend" xml:space="preserve">
<value>Unsuspend</value>
</data>
<data name="Silence {0}?" xml:space="preserve">
<value>Silence {0}?</value>
</data>
<data name="Unsilence {0}?" xml:space="preserve">
<value>Unsilence {0}?</value>
</data>
<data name="Suspend {0}?" xml:space="preserve">
<value>Suspend {0}?</value>
</data>
<data name="Unsuspend {0}?" xml:space="preserve">
<value>Unsuspend {0}?</value>
</data>
<data name="Its posts stay out of public timelines, hashtags, trends and search; its followers still read them." xml:space="preserve">
<value>Its posts stay out of public timelines, hashtags, trends and search; its followers still read them.</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="Nothing of it is shown here any more, what it sends is dropped and nothing is delivered to it. Its server is told nothing." xml:space="preserve">
<value>Nothing of it is shown here any more, what it sends is dropped and nothing is delivered to it. Its server is told nothing.</value>
</data>
<data name="Its posts are listed again." xml:space="preserve">
<value>Its posts are listed again.</value>
</data>
<data name="It is given back as it was, but the sessions a suspension ended." xml:space="preserve">
<value>It is given back as it was, but the sessions a suspension ended.</value>
</data>
<data name="{0} is silenced." xml:space="preserve">
<value>{0} is silenced.</value>
</data>
<data name="{0} is no longer silenced." xml:space="preserve">
<value>{0} is no longer silenced.</value>
</data>
<data name="{0} is suspended." xml:space="preserve">
<value>{0} is suspended.</value>
</data>
<data name="{0} is no longer suspended." xml:space="preserve">
<value>{0} is no longer suspended.</value>
</data>
<data name="A login's name, or one of its personas'" xml:space="preserve">
<value>A login's name, or one of its personas'</value>
</data>
<data name="Search logins" xml:space="preserve">
<value>Search logins</value>
</data>
<data name="The logins could not be read." xml:space="preserve">
<value>The logins could not be read.</value>
</data>
<data name="No login found." xml:space="preserve">
<value>No login found.</value>
</data>
<data name="you" xml:space="preserve">
<value>you</value>
</data>
<data name="banned" xml:space="preserve">
<value>banned</value>
</data>
<data name="two-factor" xml:space="preserve">
<value>two-factor</value>
</data>
<data name="no email address" xml:space="preserve">
<value>no email address</value>
</data>
<data name="email verified" xml:space="preserve">
<value>email verified</value>
</data>
<data name="email not verified" xml:space="preserve">
<value>email not verified</value>
</data>
<data name="Personas: {0}" xml:space="preserve">
<value>Personas: {0}</value>
</data>
<data name="Role" xml:space="preserve">
<value>Role</value>
</data>
<data name="Unban" xml:space="preserve">
<value>Unban</value>
</data>
<data name="Ban" xml:space="preserve">
<value>Ban</value>
</data>
<data name="Reset two-factor" xml:space="preserve">
<value>Reset two-factor</value>
</data>
<data name="Delete the login" xml:space="preserve">
<value>Delete the login</value>
</data>
<data name="This login has no persona." xml:space="preserve">
<value>This login has no persona.</value>
</data>
<data name="Delete the group" xml:space="preserve">
<value>Delete the group</value>
</data>
<data name="administrator" xml:space="preserve">
<value>administrator</value>
</data>
<data name="moderator" xml:space="preserve">
<value>moderator</value>
</data>
<data name="user" xml:space="preserve">
<value>user</value>
</data>
<data name="Make {0} an administrator?" xml:space="preserve">
<value>Make {0} an administrator?</value>
</data>
<data name="Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups." xml:space="preserve">
<value>Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups.</value>
</data>
<data name="Make {0} a moderator?" xml:space="preserve">
<value>Make {0} a moderator?</value>
</data>
<data name="Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings." xml:space="preserve">
<value>Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings.</value>
</data>
<data name="Make {0} a user?" xml:space="preserve">
<value>Make {0} a user?</value>
</data>
<data name="It keeps its personas, and loses what the staff do." xml:space="preserve">
<value>It keeps its personas, and loses what the staff do.</value>
</data>
<data name="Change the role" xml:space="preserve">
<value>Change the role</value>
</data>
<data name="{0} is now {1}." xml:space="preserve">
<value>{0} is now {1}.</value>
</data>
<data name="Ban {0}?" xml:space="preserve">
<value>Ban {0}?</value>
</data>
<data name="Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted." xml:space="preserve">
<value>Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted.</value>
</data>
<data name="{0} is banned." xml:space="preserve">
<value>{0} is banned.</value>
</data>
<data name="Lift {0}'s ban?" xml:space="preserve">
<value>Lift {0}'s ban?</value>
</data>
<data name="Its personas are given back as they were." xml:space="preserve">
<value>Its personas are given back as they were.</value>
</data>
<data name="{0} is no longer banned." xml:space="preserve">
<value>{0} is no longer banned.</value>
</data>
<data name="Turn off {0}'s two-factor sign-in?" xml:space="preserve">
<value>Turn off {0}'s two-factor sign-in?</value>
</data>
<data name="For a login that lost its authenticator app and its recovery codes: its password and its sessions stay." xml:space="preserve">
<value>For a login that lost its authenticator app and its recovery codes: its password and its sessions stay.</value>
</data>
<data name="{0} signs in with its password alone now." xml:space="preserve">
<value>{0} signs in with its password alone now.</value>
</data>
<data name="Delete {0}?" xml:space="preserve">
<value>Delete {0}?</value>
</data>
<data name="Every persona of it goes with it, and everything public they had, here and on the servers that hold it. Its names stay taken. It cannot be undone." xml:space="preserve">
<value>Every persona of it goes with it, and everything public they had, here and on the servers that hold it. Its names stay taken. It cannot be undone.</value>
</data>
<data name="{0} is deleted." xml:space="preserve">
<value>{0} is deleted.</value>
</data>
<data name="Delete the group {0}?" xml:space="preserve">
<value>Delete the group {0}?</value>
</data>
<data name="Everything public it had goes, here and on the servers that hold it, and the {0} groups it owns with it. Its name stays taken. It cannot be undone." xml:space="preserve">
<value>Everything public it had goes, here and on the servers that hold it, and the {0} groups it owns with it. Its name stays taken. It cannot be undone.</value>
</data>
<data name="Its members and followers are told, and its name stays taken. It cannot be undone." xml:space="preserve">
<value>Its members and followers are told, and its name stays taken. It cannot be undone.</value>
</data>
<data name="Resolve and…" xml:space="preserve">
<value>Resolve and…</value>
</data>
<data name="Resolve only" xml:space="preserve">
<value>Resolve only</value>
</data>
<data name="Silence the account" xml:space="preserve">
<value>Silence the account</value>
</data>
<data name="Suspend the account" xml:space="preserve">
<value>Suspend the account</value>
</data>
<data name="Remove the reported posts" xml:space="preserve">
<value>Remove the reported posts</value>
</data>
<data name="Only a persona here is told; an account elsewhere is told nothing." xml:space="preserve">
<value>Only a persona here is told; an account elsewhere is told nothing.</value>
</data>
<data name="The account was silenced." xml:space="preserve">
<value>The account was silenced.</value>
</data>
<data name="The account was suspended." xml:space="preserve">
<value>The account was suspended.</value>
</data>
<data name="The reported posts were removed." xml:space="preserve">
<value>The reported posts were removed.</value>
</data>
<data name="Resolved without an act." xml:space="preserve">
<value>Resolved without an act.</value>
</data>
<data name="Resolve it and silence the account?" xml:space="preserve">
<value>Resolve it and silence the account?</value>
</data>
<data name="Resolve it and suspend the account?" xml:space="preserve">
<value>Resolve it and suspend the account?</value>
</data>
<data name="Resolve it and remove the reported posts?" xml:space="preserve">
<value>Resolve it and remove the reported posts?</value>
</data>
<data name="Resolve the report?" xml:space="preserve">
<value>Resolve the report?</value>
</data>
<data name="Nothing of it is shown here any more and it can't act; nothing new is told to other servers. It can be lifted later." xml:space="preserve">
<value>Nothing of it is shown here any more and it can't act; nothing new is told to other servers. It can be lifted later.</value>
</data>
<data name="A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here." xml:space="preserve">
<value>A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here.</value>
</data>
<data name="Nothing is done to the account or its posts." xml:space="preserve">
<value>Nothing is done to the account or its posts.</value>
</data>
<data name="Remove the posts" xml:space="preserve">
<value>Remove the posts</value>
</data>
<data name="Login's role changed" xml:space="preserve">
<value>Login's role changed</value>
</data>
<data name="Login banned" xml:space="preserve">
<value>Login banned</value>
</data>
<data name="Login's ban lifted" xml:space="preserve">
<value>Login's ban lifted</value>
</data>
<data name="Account silenced" xml:space="preserve">
<value>Account silenced</value>
</data>
<data name="Account no longer silenced" xml:space="preserve">
<value>Account no longer silenced</value>
</data>
<data name="Account suspended" xml:space="preserve">
<value>Account suspended</value>
</data>
<data name="Account no longer suspended" xml:space="preserve">
<value>Account no longer suspended</value>
</data>
<data name="Persona deleted" xml:space="preserve">
<value>Persona deleted</value>
</data>
<data name="Group deleted" xml:space="preserve">
<value>Group deleted</value>
</data>
<data name="Post removed" xml:space="preserve">
<value>Post removed</value>
</data>
<data name="This login has no persona to act as yet: make one below." xml:space="preserve">
<value>This login has no persona to act as yet: make one below.</value>
</data>
<data name="Suspended by this server's staff: it can't act until that is lifted." xml:space="preserve">
<value>Suspended by this server's staff: it can't act until that is lifted.</value>
</data>
<data name="Silenced by this server's staff: its posts stay out of public timelines, trends and search." xml:space="preserve">
<value>Silenced by this server's staff: its posts stay out of public timelines, trends and search.</value>
</data>
<data name="Delete this persona" xml:space="preserve">
<value>Delete this persona</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="Deleting the group tells its members, and its name stays taken." xml:space="preserve">
<value>Deleting the group tells its members, and its name stays taken.</value>
</data>
<data name="Every persona of this login is suspended by this server's staff. Settings can still make a new one." xml:space="preserve">
<value>Every persona of this login is suspended by this server's staff. Settings can still make a new one.</value>
</data>
<data name="It is about {0} of your posts." xml:space="preserve">
<value>It is about {0} of your posts.</value>
</data>
<data name="Remove post (moderation)" xml:space="preserve">
<value>Remove post (moderation)</value>
</data>
<data name="Remove this post as a moderator?" xml:space="preserve">
<value>Remove this post as a moderator?</value>
</data>
<data name="Remove the post" xml:space="preserve">
<value>Remove the post</value>
</data>
<data name="Post removed." xml:space="preserve">
<value>Post removed.</value>
</data>
<data name="This persona is silenced: its posts stay out of public timelines, hashtags, trends and search." xml:space="preserve">
<value>This persona is silenced: its posts stay out of public timelines, hashtags, trends and search.</value>
</data>
<data name="This persona was suspended." xml:space="preserve">
<value>This persona was suspended.</value>
</data>
<data name="A post of yours was removed." xml:space="preserve">
<value>A post of yours was removed.</value>
</data>
<data name="Your media were marked as sensitive." xml:space="preserve">
<value>Your media were marked as sensitive.</value>
</data>
<data name="This login was disabled." xml:space="preserve">
<value>This login was disabled.</value>
</data>
<data name="A warning from this server's staff." xml:space="preserve">
<value>A warning from this server's staff.</value>
</data>
<data name="This server's staff warned you" xml:space="preserve">
<value>This server's staff warned you</value>
</data>
<data name="This server's staff limited it: its posts stay out of public timelines, trends and search." xml:space="preserve">
<value>This server's staff limited it: its posts stay out of public timelines, trends and search.</value>
</data>
<data name="Users &amp; personas" xml:space="preserve">
<value>Users &amp; personas</value>
</data>
<data name="{0} is suspended by this server's staff, and no other persona of this login can act now." xml:space="preserve">
<value>{0} is suspended by this server's staff, and no other persona of this login can act now.</value>
</data>
<data name="{0} is suspended by this server's staff: you now act as {1}." xml:space="preserve">
<value>{0} is suspended by this server's staff: you now act as {1}.</value>
</data>
<data name="{0} is suspended by this server's staff." xml:space="preserve">
<value>{0} is suspended by this server's staff.</value>
</data>
<data name="{0} can no longer be used, and no other persona of this login can act now." xml:space="preserve">
<value>{0} can no longer be used, and no other persona of this login can act now.</value>
</data>
<data name="{0} can no longer be used: you now act as {1}." xml:space="preserve">
<value>{0} can no longer be used: you now act as {1}.</value>
</data>
<data name="This persona can't act now: the server's staff may have suspended it." xml:space="preserve">
<value>This persona can't act now: the server's staff may have suspended it.</value>
</data>
<data name="Delete for good" xml:space="preserve">
<value>Delete for good</value>
</data>
</root> </root>
+4 -9
View File
@@ -9,11 +9,12 @@ using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary> /// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator), /// What PrivaPub's moderators and administrators read as the root: reports and the staff's log (/clientapi/moderator),
/// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and /// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and
/// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings, /// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings,
/// announcements and relays are InstanceAdminService's. PrivaPub checks the root's role on every call. Lists come back /// announcements and relays are InstanceAdminService's; resolving reports and every act on accounts, posts and logins
/// empty and values null on failure; nothing throws. /// are ModerationService's. PrivaPub checks the root's role on every call. Lists come back empty and values null on
/// failure; nothing throws.
/// </summary> /// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{ {
@@ -29,12 +30,6 @@ namespace decePubClient.Services
return []; return [];
} }
public async Task<WebResult> Resolve(string reportId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given. /// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given.
/// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary> /// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary>
public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default) public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default)
+15
View File
@@ -106,6 +106,21 @@ namespace decePubClient.Services
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) }; return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
} }
/// <summary>
/// Deletes one of the root's personas, the root's password given (a wrong one is PrivaPub's 403): everything public it
/// had goes, here and, as PrivaPub tells them, on the servers that hold it, and the groups it owns with it. Its name
/// stays taken. A suspended persona may be deleted too. The caller drops it from the session (CascadingState.PersonaDeleted).
/// </summary>
public async Task<WebResult> DeletePersona(string personaId, string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePersona, new DeletePersonaForm { AvatarId = personaId, Password = password },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await ReadPasswordFailure(response, cancellationToken);
return new WebResult();
}
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary> /// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default) public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
{ {
+19
View File
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using decePubClient.Extensions; using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
@@ -59,6 +60,24 @@ namespace decePubClient.Services
public Task<WebResult> Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) => public Task<WebResult> Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken); Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>
/// Deletes a group the persona owns, the login's password given: its members and followers are told, its name stays
/// taken. PrivaPub answers a wrong password, or a persona that isn't the owner, with 403.
/// </summary>
public async Task<WebResult> Delete(string personaId, string groupId, string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeleteGroup,
new DeleteGroupForm { AvatarId = personaId, GroupId = groupId, Password = password }, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return new WebResult();
// the owner is the only one offered it, so PrivaPub's own 403 is the password's
if (response.StatusCode is HttpStatusCode.Forbidden && !response.Content.Headers.Contains(SUtility.FailureHeader))
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Who is in a group the persona runs, and who asks to join; empty on failure.</summary> /// <summary>Who is in a group the persona runs, and who asks to join; empty on failure.</summary>
public async Task<List<ViewGroupMember>> Members(string personaId, string groupId, CancellationToken cancellationToken = default) public async Task<List<ViewGroupMember>> Members(string personaId, string groupId, CancellationToken cancellationToken = default)
{ {
+37 -6
View File
@@ -239,12 +239,23 @@ namespace decePubClient.Services
/// <summary> /// <summary>
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked, /// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked,
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the /// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
/// session ends. /// session ends. A persona PrivaPub refuses a token to while the JWT is good (suspended by the staff, or gone) is
/// marked refused (TokenAuthStateProvider.Refuse, which tells CascadingState once) and no request acts as it again.
/// </summary> /// </summary>
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken) async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{ {
var session = await authStateProvider.GetAuthData(cancellationToken); var session = await authStateProvider.GetAuthData(cancellationToken);
var token = await authStateProvider.GetPersonaToken(cancellationToken) ?? await Exchange(session.PersonaId, cancellationToken); if (authStateProvider.IsRefused(session.PersonaId))
return PersonaRefused();
var token = await authStateProvider.GetPersonaToken(cancellationToken);
if (token is null)
{
var exchanged = await Exchange(session.PersonaId, cancellationToken);
if (exchanged.Refused)
return PersonaRefused(session.PersonaId);
token = exchanged.Token;
}
if (token is null) if (token is null)
return await EndSession(cancellationToken); return await EndSession(cancellationToken);
@@ -255,7 +266,10 @@ namespace decePubClient.Services
response.Dispose(); response.Dispose();
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken); await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
token = await Exchange(session.PersonaId, cancellationToken); var renewed = await Exchange(session.PersonaId, cancellationToken);
if (renewed.Refused)
return PersonaRefused(session.PersonaId);
token = renewed.Token;
if (token is null) if (token is null)
return await EndSession(cancellationToken); return await EndSession(cancellationToken);
@@ -269,12 +283,29 @@ namespace decePubClient.Services
return await EndSession(cancellationToken); return await EndSession(cancellationToken);
} }
async Task<string> Exchange(string personaId, CancellationToken cancellationToken) /// <summary>A new token for the persona, or none; Refused when PrivaPub itself answered no (its 400 invalid_grant or a
/// 403) to a JWT the client still holds as good, rather than the request failing on the way.</summary>
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
{ {
if (personaId is null) if (personaId is null)
return null; return (null, false);
using var response = await ExchangePersonaToken(personaId, cancellationToken); using var response = await ExchangePersonaToken(personaId, cancellationToken);
return response.IsSuccessStatusCode ? await authStateProvider.GetPersonaToken(cancellationToken) : null; if (response.IsSuccessStatusCode)
return (await authStateProvider.GetPersonaToken(cancellationToken), false);
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
&& !response.Content.Headers.Contains(SUtility.FailureHeader)
&& await authStateProvider.GetToken(cancellationToken) is not null;
return (null, refused);
}
/// <summary>The answer for a request as a persona PrivaPub refuses, marking it so (once).</summary>
HttpResponseMessage PersonaRefused(string personaId = default)
{
if (personaId is not null)
authStateProvider.Refuse(personaId);
return SUtility.ClassifiedFailure(FailureCodes.PersonaRefused,
localizer["This persona can't act now: the server's staff may have suspended it."], HttpStatusCode.Forbidden);
} }
async Task<HttpResponseMessage> EndSession(CancellationToken cancellationToken) async Task<HttpResponseMessage> EndSession(CancellationToken cancellationToken)
+184
View File
@@ -0,0 +1,184 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.User;
namespace decePubClient.Services
{
/// <summary>
/// The staff's acts on accounts, posts and logins (PrivaPub's completion program R6). Moderators (/clientapi/moderator)
/// find accounts here and elsewhere, read the ones the staff moderated, silence and suspend them and undo it, remove
/// posts and resolve reports with one of those acts; a Note is the staff's own (kept in the log and on the account), a
/// Warning is what a persona here is told (Mastodon's moderation_warning; an account elsewhere is told nothing).
/// Administrators (/clientapi/admin) also read the logins with their personas and the groups those own, change a login's
/// role, ban, unban and delete it, turn its two-factor sign-in off, and delete a persona or a group with their own
/// password. PrivaPub checks the role on every call. Lists and pages come back null on failure; nothing throws.
/// </summary>
public class ModerationService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>Accounts found by a name, user@host or an actor's address: personas here (never their logins) and the
/// accounts elsewhere PrivaPub holds. Null on failure.</summary>
public async Task<ViewModeratedAccountsPage> SearchAccounts(string query, int limit, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorAccounts, [$"q={Uri.EscapeDataString(query ?? "")}", $"limit={limit}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewModeratedAccountsPage>(cancellationToken);
await LogFailure(response, nameof(SearchAccounts), cancellationToken);
return null;
}
/// <summary>What the staff silenced or suspended, here and elsewhere, most recently first; Next is the next page's
/// offset, null on the last. Null on failure.</summary>
public async Task<ViewModeratedAccountsPage> ModeratedAccounts(int offset, int limit, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratedAccounts, [$"offset={offset}", $"limit={limit}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewModeratedAccountsPage>(cancellationToken);
await LogFailure(response, nameof(ModeratedAccounts), cancellationToken);
return null;
}
/// <summary>Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions);
/// Data is the account as moderators see it now (ViewModeratedAccount).</summary>
public async Task<WebResult> Moderate(ModerateAccountForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_ModerateAccount, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewModeratedAccount>(cancellationToken) };
}
/// <summary>Takes a post down: a post here is deleted as its author would delete it (and its author warned), a copy of
/// one from elsewhere is hidden here only.</summary>
public async Task<WebResult> RemovePost(RemovePostForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_ModeratorRemovePost, form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Resolves a report, and with it what the staff does (none, silence, suspend, remove_posts). The act comes
/// first, so one that fails leaves the report open; remove_posts on a report naming no post is a 422.</summary>
public async Task<WebResult> ResolveReport(string reportId, ResolveReportForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The logins, newest first, each with its personas and the groups they own; query finds a login by its name
/// or by one of its personas' names, before is the previous page's Next. Null on failure.</summary>
public async Task<ViewAdminRootsPage> Roots(string query, string before, int limit, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (query is { Length: > 0 })
parameters.Add($"q={Uri.EscapeDataString(query)}");
if (before is { Length: > 0 })
parameters.Add($"before={Uri.EscapeDataString(before)}");
using var response = await httpService.Get(APIs.ClientApi.GET_AdminRoots, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminRootsPage>(cancellationToken);
await LogFailure(response, nameof(Roots), cancellationToken);
return null;
}
/// <summary>One login as it is now; null when it is gone (deleted) or can't be read.</summary>
public async Task<ViewAdminRoot> Root(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_M_AdminRoot(rootId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminRoot>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Root), cancellationToken);
return null;
}
/// <summary>Makes a login a user, a moderator or an administrator; never one's own nor the last administrator's (409).
/// Data is the login as it is now (ViewAdminRoot).</summary>
public async Task<WebResult> SetRole(string rootId, string role, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRootRole(rootId), new RootRoleForm { Role = role },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAdminRoot>(cancellationToken) };
}
/// <summary>Turns another login's two-factor sign-in off (it lost its authenticator and its recovery codes); its
/// password and sessions stay.</summary>
public async Task<WebResult> ResetTotp(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRootTotpReset(rootId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Bans a login: its sessions end and its personas are held back as suspended ones, without a word written on
/// any of them.</summary>
public async Task<WebResult> Ban(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBanRoots, new UsersIds { UserIdList = [rootId] },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Lifts a login's ban: its personas are given back as they were.</summary>
public async Task<WebResult> Unban(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminUnbanRoots, new UsersIds { UserIdList = [rootId] },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Deletes a login, every persona of it with everything public they had; its names stay taken.</summary>
public async Task<WebResult> DeleteRoot(string rootId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminRemoveRoots, payload: new UsersIds { UserIdList = [rootId] },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Deletes a persona, the groups it owns with it, the administrator's own password given (a wrong one is a
/// 403); the note is the staff's.</summary>
public async Task<WebResult> DeletePersona(string personaId, AdminPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminPersonaDelete(personaId), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await ReadPasswordFailure(response, cancellationToken);
}
/// <summary>Deletes a group, the administrator's own password given (a wrong one is a 403): its members are told, its
/// name stays taken.</summary>
public async Task<WebResult> DeleteGroup(string groupId, AdminPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminGroupDelete(groupId), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await ReadPasswordFailure(response, cancellationToken);
}
/// <summary>PrivaPub refuses a wrong password with its own 403, which ReadWebResult would take for a missing
/// permission.</summary>
async Task<WebResult> ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ModerationService)}.{caller}", cancellationToken);
}
}
}
+26
View File
@@ -23,6 +23,12 @@ namespace decePubClient.Services
AuthData authData; AuthData authData;
bool loaded; bool loaded;
/// <summary>The personas PrivaPub refused a token to while the JWT was good (suspended, or gone): no request acts as
/// them again until the reader picks one anew or the session is read again.</summary>
readonly HashSet<string> refusedPersonas = [];
/// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary>
public event Action<string> PersonaRefused;
/// <summary>The stored session, or an empty one.</summary> /// <summary>The stored session, or an empty one.</summary>
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default) public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
@@ -89,6 +95,25 @@ namespace decePubClient.Services
await Save(session, cancellationToken); await Save(session, cancellationToken);
} }
/// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary>
public bool IsRefused(string personaId) => personaId is not null && refusedPersonas.Contains(personaId);
/// <summary>PrivaPub refused the persona a token while the JWT was good: no request acts as it from now on, and
/// PersonaRefused tells the session, once.</summary>
public void Refuse(string personaId)
{
if (personaId is null || !refusedPersonas.Add(personaId))
return;
PersonaRefused?.Invoke(personaId);
}
/// <summary>The persona may be tried again: the reader picked it, or PrivaPub lists it as usable again.</summary>
public void Unrefuse(string personaId)
{
if (personaId is not null)
refusedPersonas.Remove(personaId);
}
/// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary> /// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary>
public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default) public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default)
{ {
@@ -104,6 +129,7 @@ namespace decePubClient.Services
logger.LogInformation($"set null({nameof(LogoutAsync)})"); logger.LogInformation($"set null({nameof(LogoutAsync)})");
authData = null; authData = null;
loaded = true; loaded = true;
refusedPersonas.Clear();
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken); await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
+8 -1
View File
@@ -37,10 +37,17 @@
<ChildContent> <ChildContent>
@foreach (var persona in CascadingState.Personas) @foreach (var persona in CascadingState.Personas)
{ {
@* a persona the staff suspended can't be chosen until that is lifted *@
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="persona.Id == CascadingState.PersonaId" <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="persona.Id == CascadingState.PersonaId"
Disabled="IsDefaultDisabled || persona.Id == CascadingState.PersonaId" OnClick="() => SwitchPersona(persona.Id)"> Disabled="IsDefaultDisabled || persona.Id == CascadingState.PersonaId || !CascadingState.CanUse(persona)"
OnClick="() => SwitchPersona(persona.Id)" data-persona-switch="@persona.UserName"
data-persona-state="@(CascadingState.CanUse(persona) ? "normal" : "suspended")">
<img alt="" class="h-5 w-5 object-cover rounded-full" src="@(persona.PictureURL ?? "imgs/icon-192.png")" /> <img alt="" class="h-5 w-5 object-cover rounded-full" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
<span class="truncate">@(persona.Name ?? persona.UserName)</span> <span class="truncate">@(persona.Name ?? persona.UserName)</span>
@if (!CascadingState.CanUse(persona))
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" class="ml-auto">@Localizer["Suspended"]</DBadge>
}
</DButton> </DButton>
} }
</ChildContent> </ChildContent>