diff --git a/CLAUDE.md b/CLAUDE.md index 3af8b1c..da65e20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - **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 - group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the - server's thumbnail. + group, dropping a scheduled post, signing out, and for an administrator deleting an announcement, removing the + 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:** - 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; @@ -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; - the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`); - `IsOnline` / `IsOffline`, polled every 10 seconds; -- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), with - `LoadSession`, `SwitchPersona` and `SignOut`. A persona change re-renders every subscriber; pages compare - `PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed; +- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), `IsModerator` + and `IsAdmin` (the JWT's policies), with `LoadSession`, `SwitchPersona`, `PersonaDeleted` and `SignOut`. A persona + 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; - `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` (`/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 - `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 reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and `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 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), - 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, 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 @@ -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. - **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 - left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out. - Only a group's owner and moderators see who is in it. + left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out; the + 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 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. @@ -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 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 - 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, 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, @@ -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 log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`), Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups - (`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators: - `/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until - then they have no section. + (`backups`). Moderators also see Accounts (`accounts`), administrators Users & personas (`users`). PrivaPub checks the + role on every call (moderators: `/clientapi/moderator`, administrators: `/clientapi/admin`, checked against its database + 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 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 @@ -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-note`). `data-log-reload`. - **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' `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. @@ -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 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 - 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 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 diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 7927b86..b045bd6 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -99,6 +99,18 @@ namespace decePubClient; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(RelayForm))] [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 { } diff --git a/Components/ActDialog.razor b/Components/ActDialog.razor new file mode 100644 index 0000000..fdf6098 --- /dev/null +++ b/Components/ActDialog.razor @@ -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. *@ + + @Question + +
+ @if (Text is { Length: > 0 }) + { +

@Text

+ } + @if (AskNote) + { + + } + @if (AskWarning) + { + + } + @if (AskPassword) + { + + } + +
+
+ + @Localizer["Cancel"] + + @if (isActing) + { + + } + @ConfirmText + + +
+ +@code { + /// The question the dialog asks, its heading. + [Parameter, EditorRequired] public string Question { get; set; } + /// What the act does, in words. + [Parameter] public string Text { get; set; } + [Parameter, EditorRequired] public string ConfirmText { get; set; } + /// The confirm button shows as a danger (it deletes or cuts something off). + [Parameter] public bool Danger { get; set; } + /// The act's name in the dialog's data attribute (silence, persona-delete, ...). + [Parameter, EditorRequired] public string Act { get; set; } + /// A staff act (data-moderate-confirm, the administrator's password) or the login's own (data-own-confirm). + [Parameter] public bool Staff { get; set; } + [Parameter] public bool AskNote { get; set; } + [Parameter] public bool AskWarning { get; set; } + /// Who reads the warning, when it isn't simply the persona (an account elsewhere is told nothing). + [Parameter] public string WarningHint { get; set; } + [Parameter] public bool AskPassword { get; set; } + /// Does the act with what was typed; an invalid result stays in the dialog, a valid one closes it. + [Parameter, EditorRequired] public Func> OnConfirm { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + + /// What was typed: the note and the warning trimmed, null when empty; the password as typed. + 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(); +} diff --git a/Components/Admin/AccountsPanel.razor b/Components/Admin/AccountsPanel.razor new file mode 100644 index 0000000..b24b451 --- /dev/null +++ b/Components/Admin/AccountsPanel.razor @@ -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. *@ +
+ @* its form attribute names no form, so Enter in the search submits nothing *@ + + + + + @if (query is { Length: > 0 }) + { +
+ @if (isSearching) + { +

@Localizer["loading"]

+ } + else if (found.Count == 0) + { +

@Localizer["No account found."]

+ } + @foreach (var account in found) + { + @Row(account) + } +
+ } + + @Localizer["Silenced and suspended"] + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (failed) + { +
+

@Localizer["The accounts could not be read."]

+ @Localizer["Retry"] +
+ } + else if (moderated.Count == 0) + { +

@Localizer["No account is silenced or suspended."]

+ } +
+ @foreach (var account in moderated) + { + @Row(account) + } +
+ @if (next is not null) + { + + @if (isLoadingMore) + { + + } + @Localizer["Load more"] + + } +
+ +@if (acting is not null) +{ + +} + +@code { + const int PageSize = 50; + const int SearchSize = 20; + + [Inject] ModerationService ModerationService { get; set; } + + string query; + List found = []; + List 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) => @
+
+ + @(account.DisplayName is { Length: > 0 } displayName ? displayName : account.UserName) + @("@" + account.Handle) + + @(account.IsLocal ? Localizer["here"] : account.Domain) + @if (account.SilencedAt is { } silencedAt) + { + @Localizer["Silenced"] + } + @if (account.SuspendedAt is { } suspendedAt) + { + @Localizer["Suspended"] + } + @if (account.DomainSilenced) + { + @Localizer["its server is silenced"] + } + @if (account.DomainSuspended) + { + @Localizer["its server is suspended"] + } +
+ @if (account.Note is { Length: > 0 } staffNote) + { +

@Localizer["Staff note"] @staffNote

+ } +
+ @if (account.SilencedAt is null) + { + + @Localizer["Silence"] + + } + else + { + + @Localizer["Unsilence"] + + } + @if (account.SuspendedAt is null) + { + + @Localizer["Suspend"] + + } + else + { + + @Localizer["Unsuspend"] + + } +
+
; + + 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 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)] + }; + } +} diff --git a/Components/Admin/ModerationLogPanel.razor b/Components/Admin/ModerationLogPanel.razor index ee5af5c..d473afc 100644 --- a/Components/Admin/ModerationLogPanel.razor +++ b/Components/Admin/ModerationLogPanel.razor @@ -156,6 +156,16 @@ "root.delete" => Localizer["Login deleted"], "root.reset_totp" => Localizer["Two-factor sign-in reset"], "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 }; } diff --git a/Components/Admin/ReportsPanel.razor b/Components/Admin/ReportsPanel.razor index bdd0059..973c966 100644 --- a/Components/Admin/ReportsPanel.razor +++ b/Components/Admin/ReportsPanel.razor @@ -2,7 +2,9 @@ @* 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 - 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. *@
@@ -33,16 +35,54 @@ @if (!report.IsResolved) { - - @Localizer["Resolve"] - + + + @Localizer["Resolve and…"] + + + + @Localizer["Resolve only"] + + @if (report.TargetAccountId is { Length: > 0 }) + { + + @Localizer["Silence the account"] + + + @Localizer["Suspend the account"] + + } + @if (report.PostIds.Count > 0) + { + + @Localizer["Remove the reported posts"] + + } + + } else if (report.ResolvedAt is { } resolvedAt) { @Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)] }
+ @if (report.IsResolved && report.Resolution is { Length: > 0 } resolution) + { +
+ @ResolutionName(resolution) + @if (report.ResolutionNote is { Length: > 0 } resolutionNote) + { + @resolutionNote + } +
+ }

@Localizer["About"] @if (report.TargetAccountId is { Length: > 0 } targetId) @@ -104,12 +144,24 @@ } +@if (resolving is not null) +{ + +} + @code { [Inject] AdminService AdminService { get; set; } + [Inject] ModerationService ModerationService { get; set; } List reports = []; bool resolved; - HashSet 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() { @@ -147,18 +199,66 @@ _ => Localizer["Other"] }; - async Task Resolve(ViewReport report) + string ResolutionName(string action) => action switch { - busy.Add(report.ID); - var result = await AdminService.Resolve(report.ID); - busy.Remove(report.ID); - if (result.IsInvalid) - { - Toast.AddResponseError(result); - return; - } + ModerationActions.Silence => Localizer["The account was silenced."], + ModerationActions.Suspend => Localizer["The account was suspended."], + ModerationActions.RemovePosts => Localizer["The reported posts were removed."], + _ => Localizer["Resolved without an act."] + }; - reports.Remove(report); + string ResolveQuestion => resolveAction switch + { + ModerationActions.Silence => Localizer["Resolve it and silence the account?"], + 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; + } + + void CloseResolve() => (resolving, resolveAction) = (null, null); + + async Task 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."]); + return result; } } diff --git a/Components/Admin/UsersPanel.razor b/Components/Admin/UsersPanel.razor new file mode 100644 index 0000000..cbf0e30 --- /dev/null +++ b/Components/Admin/UsersPanel.razor @@ -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. *@ +

+
+ @* its form attribute names no form, so Enter in the search submits nothing *@ + + + + + @Localizer["Read again"] + +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (failed) + { +
+

@Localizer["The logins could not be read."]

+ @Localizer["Retry"] +
+ } + else if (roots.Count == 0) + { +

@Localizer["No login found."]

+ } + +
+ @foreach (var root in roots) + { +
+
+ + @root.UserName + @if (root.IsYou) + { + @Localizer["you"] + } + @RoleName(root.Role) + @if (root.IsBanned) + { + @Localizer["banned"] + } + @if (root.HasTotp) + { + @Localizer["two-factor"] + } + @if (!root.HasEmail) + { + @Localizer["no email address"] + } + else if (root.IsEmailValidated) + { + @Localizer["email verified"] + } + else + { + @Localizer["email not verified"] + } + + @root.CreatedAt.GetPassedTime(Localizer) +
+ +
+ + + @Localizer["Personas: {0}", root.Personas.Count] + + + @if (!root.IsYou) + { + + + + + + @if (root.IsBanned) + { + + @Localizer["Unban"] + + } + else + { + + @Localizer["Ban"] + + } + @if (root.HasTotp) + { + + @Localizer["Reset two-factor"] + + } + + @Localizer["Delete the login"] + + } +
+ + @if (opened.Contains(root.Id)) + { +
+ @if (root.Personas.Count == 0) + { +

@Localizer["This login has no persona."]

+ } + @foreach (var persona in root.Personas) + { +
+
+ + + @(persona.DisplayName is { Length: > 0 } displayName ? displayName : persona.UserName) + + @("@" + persona.Handle) + + @if (persona.SilencedAt is { } silencedAt) + { + + @Localizer["Silenced"] + + } + @if (persona.SuspendedAt is { } suspendedAt) + { + + @Localizer["Suspended"] + + } +
+
+ @if (persona.SilencedAt is null) + { + + @Localizer["Silence"] + + } + else + { + + @Localizer["Unsilence"] + + } + @if (persona.SuspendedAt is null) + { + + @Localizer["Suspend"] + + } + else + { + + @Localizer["Unsuspend"] + + } + + @Localizer["Delete"] + +
+ @foreach (var group in persona.Groups) + { +
+ + + @(group.Name ?? group.UserName) @("@" + group.UserName) + + @group.MembersCount + + + +
+ } +
+ } +
+ } +
+ } +
+ + @if (next is not null) + { + + @if (isLoadingMore) + { + + } + @Localizer["Load more"] + + } +
+ +@if (act is not null) +{ + +} + +@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 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 opened = []; + // the logins being changed + HashSet 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> 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 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; + } +} diff --git a/Components/Content.razor b/Components/Content.razor index a1207f5..f59c784 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -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 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 - may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats - are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@ + may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line, and a + 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 (notification.ModerationWarning is { } warning) + { +
+ @WarningActionText(warning.Action) + @if (warning.Text is { Length: > 0 } warningText) + { +

@warningText

+ } + @if (warning.StatusIds.Count > 0) + { +

@Localizer["It is about {0} of your posts.", warning.StatusIds.Count]

+ } +
+ } } else if (Post.BoostedBy is { } booster) { @@ -434,6 +450,14 @@ @Localizer["Block"] + @if (CascadingState.IsModerator && !Post.Located) + { + + + @Localizer["Remove post (moderation)"] + + } } else { @@ -508,6 +532,14 @@ { } + @if (isModeratingPost) + { +
} @@ -186,6 +194,14 @@ +@if (deleting is not null) +{ + +} + @code { static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"]; @@ -200,6 +216,8 @@ bool editDiscoverable; bool editApproves; ViewGroup open; + // the group whose deletion the dialog asks about + ViewGroup deleting; string loadedPersonaId; bool isSaving; @@ -300,6 +318,23 @@ Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]); } + async Task 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) { if (open?.Id == group.Id) diff --git a/Pages/Login.razor b/Pages/Login.razor index cccc44d..274473d 100644 --- a/Pages/Login.razor +++ b/Pages/Login.razor @@ -114,14 +114,27 @@

@Localizer["Who are you today?"]

@Localizer["Each persona has its own token; PrivaPub never tells anyone they share a login."]

+ @if (CascadingState.Personas.All(p => !CascadingState.CanUse(p))) + { +

+ @Localizer["Every persona of this login is suspended by this server's staff. Settings can still make a new one."] +

+ @Localizer["Settings"] + } @foreach (var persona in CascadingState.Personas) { -
@if (!string.IsNullOrWhiteSpace(account.Note)) diff --git a/Program.cs b/Program.cs index 72149e0..ab83b27 100644 --- a/Program.cs +++ b/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 34c99a9..71abc33 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -3086,4 +3086,400 @@ la riga di comando + + Una nota per lo staff (facoltativa) + + + La legge solo lo staff, nel registro di moderazione. + + + Cosa viene detto alla persona (facoltativo) + + + Arriva alla persona come avviso dello staff di questo server. + + + La tua password + + + Un nome, utente@host o un indirizzo + + + Cerca account + + + Nessun account trovato. + + + Silenziati e sospesi + + + Non è stato possibile leggere gli account. + + + Nessun account è silenziato o sospeso. + + + qui + + + il suo server è silenziato + + + il suo server è sospeso + + + Nota dello staff + + + Silenzia + + + Togli il silenzio + + + Sospendi + + + Revoca la sospensione + + + Silenziare {0}? + + + Togliere il silenzio a {0}? + + + Sospendere {0}? + + + Revocare la sospensione di {0}? + + + I suoi post restano fuori dalle timeline pubbliche, dagli hashtag, dalle tendenze e dalla ricerca; i suoi follower continuano a leggerli. + + + 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. + + + 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. + + + I suoi post tornano a essere elencati. + + + Torna com'era, tranne le sessioni che la sospensione ha chiuso. + + + {0} è silenziato. + + + {0} non è più silenziato. + + + {0} è sospeso. + + + {0} non è più sospeso. + + + Il nome di un accesso, o di una delle sue persone + + + Cerca accessi + + + Non è stato possibile leggere gli accessi. + + + Nessun accesso trovato. + + + tu + + + bandito + + + due fattori + + + nessun indirizzo email + + + email verificata + + + email non verificata + + + Persone: {0} + + + Ruolo + + + Togli il bando + + + Bandisci + + + Azzera i due fattori + + + Elimina l'accesso + + + Questo accesso non ha persone. + + + Elimina il gruppo + + + amministratore + + + moderatore + + + utente + + + Rendere {0} amministratore? + + + Gli amministratori fanno tutto ciò che fanno i moderatori, e cambiano gli accessi, le impostazioni del server, i suoi relay e i suoi backup. + + + Rendere {0} moderatore? + + + I moderatori gestiscono le segnalazioni, silenziano e sospendono account e rimuovono post; mai gli accessi né le impostazioni del server. + + + Rendere {0} utente? + + + Mantiene le sue persone e perde ciò che fa lo staff. + + + Cambia il ruolo + + + {0} ora è {1}. + + + Bandire {0}? + + + Le sue sessioni finiscono e le sue persone vengono trattenute come sospese, senza scrivere nulla su nessuna di esse. Il bando si può togliere. + + + {0} è bandito. + + + Togliere il bando a {0}? + + + Le sue persone tornano com'erano. + + + {0} non è più bandito. + + + Disattivare l'accesso a due fattori di {0}? + + + Per un accesso che ha perso l'app di autenticazione e i codici di recupero: la password e le sessioni restano. + + + Ora {0} accede con la sola password. + + + Eliminare {0}? + + + 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. + + + {0} è stato eliminato. + + + Eliminare il gruppo {0}? + + + 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. + + + I suoi membri e follower vengono avvisati, e il suo nome resta occupato. Non si può annullare. + + + Risolvi e… + + + Risolvi soltanto + + + Silenzia l'account + + + Sospendi l'account + + + Rimuovi i post segnalati + + + Viene avvisata solo una persona di qui; a un account altrove non viene detto nulla. + + + L'account è stato silenziato. + + + L'account è stato sospeso. + + + I post segnalati sono stati rimossi. + + + Risolta senza alcun intervento. + + + Risolverla e silenziare l'account? + + + Risolverla e sospendere l'account? + + + Risolverla e rimuovere i post segnalati? + + + Risolvere la segnalazione? + + + 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. + + + Un post scritto qui viene eliminato come lo eliminerebbe il suo autore; la copia di uno di altrove viene nascosta qui. + + + Non si fa nulla all'account né ai suoi post. + + + Rimuovi i post + + + Ruolo dell'accesso cambiato + + + Accesso bandito + + + Bando dell'accesso tolto + + + Account silenziato + + + Account non più silenziato + + + Account sospeso + + + Account non più sospeso + + + Persona eliminata + + + Gruppo eliminato + + + Post rimosso + + + Questo accesso non ha ancora una persona con cui agire: creane una qui sotto. + + + Sospesa dallo staff di questo server: non può agire finché la sospensione non viene revocata. + + + Silenziata dallo staff di questo server: i suoi post restano fuori dalle timeline pubbliche, dalle tendenze e dalla ricerca. + + + Elimina questa persona + + + 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. + + + Eliminare il gruppo lo comunica ai suoi membri, e il suo nome resta occupato. + + + Tutte le persone di questo accesso sono sospese dallo staff di questo server. Dalle impostazioni se ne può comunque creare una nuova. + + + Riguarda {0} dei tuoi post. + + + Rimuovi il post (moderazione) + + + Rimuovere questo post come moderatore? + + + Rimuovi il post + + + Post rimosso. + + + Questa persona è silenziata: i suoi post restano fuori dalle timeline pubbliche, dagli hashtag, dalle tendenze e dalla ricerca. + + + Questa persona è stata sospesa. + + + Un tuo post è stato rimosso. + + + I tuoi media sono stati segnati come sensibili. + + + Questo accesso è stato disattivato. + + + Un avviso dello staff di questo server. + + + Lo staff di questo server ti ha avvisato + + + Lo staff di questo server lo ha limitato: i suoi post restano fuori dalle timeline pubbliche, dalle tendenze e dalla ricerca. + + + Utenti e persone + + + {0} è sospesa dallo staff di questo server, e nessun'altra persona di questo accesso può agire ora. + + + {0} è sospesa dallo staff di questo server: ora agisci come {1}. + + + {0} è sospesa dallo staff di questo server. + + + {0} non si può più usare, e nessun'altra persona di questo accesso può agire ora. + + + {0} non si può più usare: ora agisci come {1}. + + + Questa persona ora non può agire: lo staff del server potrebbe averla sospesa. + + + Elimina per sempre + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 4625c7f..5e64bd4 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -3086,4 +3086,400 @@ the command line + + A note for the staff (optional) + + + Only the staff read it, in the moderation log. + + + What the persona is told (optional) + + + It reaches the persona as a warning from this server's staff. + + + Your password + + + A name, user@host or an address + + + Search accounts + + + No account found. + + + Silenced and suspended + + + The accounts could not be read. + + + No account is silenced or suspended. + + + here + + + its server is silenced + + + its server is suspended + + + Staff note + + + Silence + + + Unsilence + + + Suspend + + + Unsuspend + + + Silence {0}? + + + Unsilence {0}? + + + Suspend {0}? + + + Unsuspend {0}? + + + Its posts stay out of public timelines, hashtags, trends and search; its followers still read them. + + + 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. + + + Nothing of it is shown here any more, what it sends is dropped and nothing is delivered to it. Its server is told nothing. + + + Its posts are listed again. + + + It is given back as it was, but the sessions a suspension ended. + + + {0} is silenced. + + + {0} is no longer silenced. + + + {0} is suspended. + + + {0} is no longer suspended. + + + A login's name, or one of its personas' + + + Search logins + + + The logins could not be read. + + + No login found. + + + you + + + banned + + + two-factor + + + no email address + + + email verified + + + email not verified + + + Personas: {0} + + + Role + + + Unban + + + Ban + + + Reset two-factor + + + Delete the login + + + This login has no persona. + + + Delete the group + + + administrator + + + moderator + + + user + + + Make {0} an administrator? + + + Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups. + + + Make {0} a moderator? + + + Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings. + + + Make {0} a user? + + + It keeps its personas, and loses what the staff do. + + + Change the role + + + {0} is now {1}. + + + Ban {0}? + + + Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted. + + + {0} is banned. + + + Lift {0}'s ban? + + + Its personas are given back as they were. + + + {0} is no longer banned. + + + Turn off {0}'s two-factor sign-in? + + + For a login that lost its authenticator app and its recovery codes: its password and its sessions stay. + + + {0} signs in with its password alone now. + + + Delete {0}? + + + 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. + + + {0} is deleted. + + + Delete the group {0}? + + + 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. + + + Its members and followers are told, and its name stays taken. It cannot be undone. + + + Resolve and… + + + Resolve only + + + Silence the account + + + Suspend the account + + + Remove the reported posts + + + Only a persona here is told; an account elsewhere is told nothing. + + + The account was silenced. + + + The account was suspended. + + + The reported posts were removed. + + + Resolved without an act. + + + Resolve it and silence the account? + + + Resolve it and suspend the account? + + + Resolve it and remove the reported posts? + + + Resolve the report? + + + 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. + + + A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here. + + + Nothing is done to the account or its posts. + + + Remove the posts + + + Login's role changed + + + Login banned + + + Login's ban lifted + + + Account silenced + + + Account no longer silenced + + + Account suspended + + + Account no longer suspended + + + Persona deleted + + + Group deleted + + + Post removed + + + This login has no persona to act as yet: make one below. + + + Suspended by this server's staff: it can't act until that is lifted. + + + Silenced by this server's staff: its posts stay out of public timelines, trends and search. + + + Delete this persona + + + 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. + + + Deleting the group tells its members, and its name stays taken. + + + Every persona of this login is suspended by this server's staff. Settings can still make a new one. + + + It is about {0} of your posts. + + + Remove post (moderation) + + + Remove this post as a moderator? + + + Remove the post + + + Post removed. + + + This persona is silenced: its posts stay out of public timelines, hashtags, trends and search. + + + This persona was suspended. + + + A post of yours was removed. + + + Your media were marked as sensitive. + + + This login was disabled. + + + A warning from this server's staff. + + + This server's staff warned you + + + This server's staff limited it: its posts stay out of public timelines, trends and search. + + + Users & personas + + + {0} is suspended by this server's staff, and no other persona of this login can act now. + + + {0} is suspended by this server's staff: you now act as {1}. + + + {0} is suspended by this server's staff. + + + {0} can no longer be used, and no other persona of this login can act now. + + + {0} can no longer be used: you now act as {1}. + + + This persona can't act now: the server's staff may have suspended it. + + + Delete for good + diff --git a/Services/AdminService.cs b/Services/AdminService.cs index 79d821b..60a92ac 100644 --- a/Services/AdminService.cs +++ b/Services/AdminService.cs @@ -9,11 +9,12 @@ using PrivaPub.ClientModels.Admin; namespace decePubClient.Services { /// - /// 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 /// 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 - /// empty and values null on failure; nothing throws. + /// announcements and relays are InstanceAdminService's; resolving reports and every act on accounts, posts and logins + /// are ModerationService's. PrivaPub checks the root's role on every call. Lists come back empty and values null on + /// failure; nothing throws. /// public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { @@ -29,12 +30,6 @@ namespace decePubClient.Services return []; } - public async Task 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); - } - /// 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. public async Task Log(string before, int limit, CancellationToken cancellationToken = default) diff --git a/Services/AuthService.cs b/Services/AuthService.cs index f159f18..4e8999f 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -106,6 +106,21 @@ namespace decePubClient.Services return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; } + /// + /// 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). + /// + public async Task 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(); + } + /// Acts as the persona from now on, exchanging a token for it unless it has one. public async Task UsePersona(string personaId, CancellationToken cancellationToken = default) { diff --git a/Services/GroupService.cs b/Services/GroupService.cs index 921971f..d388a6f 100644 --- a/Services/GroupService.cs +++ b/Services/GroupService.cs @@ -1,6 +1,7 @@ using System.Net; using decePubClient.Extensions; +using decePubClient.Helpers; using decePubClient.Models; using PrivaPub.ClientModels; @@ -59,6 +60,24 @@ namespace decePubClient.Services public Task Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) => Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken); + /// + /// 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. + /// + public async Task 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); + } + /// Who is in a group the persona runs, and who asks to join; empty on failure. public async Task> Members(string personaId, string groupId, CancellationToken cancellationToken = default) { diff --git a/Services/IHttpService.cs b/Services/IHttpService.cs index a0ff0f4..122d27a 100644 --- a/Services/IHttpService.cs +++ b/Services/IHttpService.cs @@ -239,12 +239,23 @@ namespace decePubClient.Services /// /// 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 - /// 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. /// async Task SendAsPersona(HttpRequestMessage request, Func requestFactory, CancellationToken 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) return await EndSession(cancellationToken); @@ -255,7 +266,10 @@ namespace decePubClient.Services response.Dispose(); 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) return await EndSession(cancellationToken); @@ -269,12 +283,29 @@ namespace decePubClient.Services return await EndSession(cancellationToken); } - async Task Exchange(string personaId, CancellationToken cancellationToken) + /// 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. + async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken) { if (personaId is null) - return null; + return (null, false); 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); + } + + /// The answer for a request as a persona PrivaPub refuses, marking it so (once). + 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 EndSession(CancellationToken cancellationToken) diff --git a/Services/ModerationService.cs b/Services/ModerationService.cs new file mode 100644 index 0000000..74f9168 --- /dev/null +++ b/Services/ModerationService.cs @@ -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 +{ + /// + /// 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. + /// + public class ModerationService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + /// 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. + public async Task 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(cancellationToken); + + await LogFailure(response, nameof(SearchAccounts), cancellationToken); + return null; + } + + /// 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. + public async Task 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(cancellationToken); + + await LogFailure(response, nameof(ModeratedAccounts), cancellationToken); + return null; + } + + /// Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions); + /// Data is the account as moderators see it now (ViewModeratedAccount). + public async Task 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(cancellationToken) }; + } + + /// 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. + public async Task 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); + } + + /// 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. + public async Task 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); + } + + /// 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. + public async Task Roots(string query, string before, int limit, CancellationToken cancellationToken = default) + { + var parameters = new List { $"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(cancellationToken); + + await LogFailure(response, nameof(Roots), cancellationToken); + return null; + } + + /// One login as it is now; null when it is gone (deleted) or can't be read. + public async Task 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(cancellationToken); + + if (response.StatusCode is not HttpStatusCode.NotFound) + await LogFailure(response, nameof(Root), cancellationToken); + return null; + } + + /// 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). + public async Task 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(cancellationToken) }; + } + + /// Turns another login's two-factor sign-in off (it lost its authenticator and its recovery codes); its + /// password and sessions stay. + public async Task 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); + } + + /// Bans a login: its sessions end and its personas are held back as suspended ones, without a word written on + /// any of them. + public async Task 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); + } + + /// Lifts a login's ban: its personas are given back as they were. + public async Task 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); + } + + /// Deletes a login, every persona of it with everything public they had; its names stay taken. + public async Task 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); + } + + /// 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. + public async Task 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); + } + + /// Deletes a group, the administrator's own password given (a wrong one is a 403): its members are told, its + /// name stays taken. + public async Task 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); + } + + /// PrivaPub refuses a wrong password with its own 403, which ReadWebResult would take for a missing + /// permission. + async Task 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); + } + } +} diff --git a/Services/TokenAuthStateProvider.cs b/Services/TokenAuthStateProvider.cs index 9957cfb..3ff8867 100644 --- a/Services/TokenAuthStateProvider.cs +++ b/Services/TokenAuthStateProvider.cs @@ -23,6 +23,12 @@ namespace decePubClient.Services AuthData authData; bool loaded; + /// 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. + readonly HashSet refusedPersonas = []; + + /// Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on. + public event Action PersonaRefused; /// The stored session, or an empty one. public async Task GetAuthData(CancellationToken cancellationToken = default) @@ -89,6 +95,25 @@ namespace decePubClient.Services await Save(session, cancellationToken); } + /// Whether PrivaPub refused the persona a token in this session (Refuse). + public bool IsRefused(string personaId) => personaId is not null && refusedPersonas.Contains(personaId); + + /// 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. + public void Refuse(string personaId) + { + if (personaId is null || !refusedPersonas.Add(personaId)) + return; + PersonaRefused?.Invoke(personaId); + } + + /// The persona may be tried again: the reader picked it, or PrivaPub lists it as usable again. + public void Unrefuse(string personaId) + { + if (personaId is not null) + refusedPersonas.Remove(personaId); + } + /// Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used. public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default) { @@ -104,6 +129,7 @@ namespace decePubClient.Services logger.LogInformation($"set null({nameof(LogoutAsync)})"); authData = null; loaded = true; + refusedPersonas.Clear(); await storage.RemoveItemAsync(nameof(AuthData), cancellationToken); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index ef7c577..2a44dbf 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -37,10 +37,17 @@ @foreach (var persona in CascadingState.Personas) { + @* a persona the staff suspended can't be chosen until that is lifted *@ + 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")"> @(persona.Name ?? persona.UserName) + @if (!CascadingState.CanUse(persona)) + { + @Localizer["Suspended"] + } }