R6 moderation in decePub: users and personas, accounts, report actions, deletions, suspended personas
Administration's Users & personas (administrators): logins searched and opened into their personas and groups; roles, bans, two-factor reset and deletion of a login; silence, suspension and deletion of a persona; a group deleted. Accounts (moderators): local personas and remote accounts searched or listed as moderated, silenced and suspended with a staff note and a warning. Reports resolve with an action. ActDialog asks for the note, the warning or the password and is the act's confirmation. A login deletes its own persona and the groups it owns with its password. Suspended personas can't be chosen; one refused a token mid-session moves the session to another, or to none, once. Warnings show in notifications, limited accounts on their profile, and moderators remove a post from its card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
4c88980374
commit
c572471b88
30 files changed
+2601
-64
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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))]
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
@@ -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 & personas" xml:space="preserve">
|
||||||
|
<value>Users & 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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user