@inherits LocalizableComponentBase @* The logins, for administrators (/clientapi/admin/roots): the one place a login is shown with its personas and the groups they own. Newest first, 50 at a time, found by a login's name or a persona's. A login's role (user, moderator, administrator), its ban, its two-factor sign-in and its deletion are asked first (ConfirmService); never one's own, and never the last administrator's role (PrivaPub answers 409). A persona is silenced, suspended or given back with a note for the staff and a warning it is told; a persona or a group is deleted with the administrator's own password. Every act is kept in the staff's log. *@
@* its form attribute names no form, so Enter in the search submits nothing *@ @Localizer["Read again"]
@if (IsLoading) {

@Localizer["loading"]

} else if (failed) {

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

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

@Localizer["No login found."]

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

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

} @foreach (var persona in root.Personas) {
@(persona.DisplayName is { Length: > 0 } displayName ? displayName : persona.UserName) @("@" + persona.Handle) @if (persona.SilencedAt is { } silencedAt) { @Localizer["Silenced"] } @if (persona.SuspendedAt is { } suspendedAt) { @Localizer["Suspended"] }
@if (persona.SilencedAt is null) { @Localizer["Silence"] } else { @Localizer["Unsilence"] } @if (persona.SuspendedAt is null) { @Localizer["Suspend"] } else { @Localizer["Unsuspend"] } @Localizer["Delete"]
@foreach (var group in persona.Groups) {
@(group.Name ?? group.UserName) @("@" + group.UserName) @group.MembersCount
}
}
}
}
@if (next is not null) { @if (isLoadingMore) { } @Localizer["Load more"] }
@if (act is not null) { } @code { const int PageSize = 50; const string PersonaDelete = "persona-delete"; const string GroupDelete = "group-delete"; [Inject] ModerationService ModerationService { get; set; } [Inject] ConfirmService Confirm { get; set; } string query; List roots = []; // the next page's `before`; null on the last string next; bool failed; bool isLoadingMore; int searching; // the logins whose personas are shown HashSet opened = []; // the logins being changed HashSet busy = []; // rebuilds the role selects, so one whose change was cancelled shows the role again int roleRenders; // the act the dialog asks about (a persona's silence or suspension, a persona's or a group's deletion) and its targets string act; ViewAdminRoot actingRoot; ViewAdminPersona actingPersona; ViewAdminGroup actingGroup; protected override async Task OnInitializedAsync() { if (CascadingState.IsOffline) { WhenOnline(LoadData); StartOnlinePolling(); IsLoading = false; return; } await LoadData(); IsLoading = false; } async Task LoadData() { var page = await ModerationService.Roots(query?.Trim(), null, PageSize); failed = page is null; roots = page?.Items ?? []; next = page?.Next; } async Task Reload() { IsLoading = true; await LoadData(); IsLoading = false; } async Task Search(string text) { query = text; var lookup = ++searching; await Task.Delay(300); if (lookup != searching) return; IsLoading = true; await LoadData(); if (lookup != searching) return; IsLoading = false; } async Task More() { isLoadingMore = true; var page = await ModerationService.Roots(query?.Trim(), next, PageSize); isLoadingMore = false; if (page is null) { Toast.AddResponseError(Localizer["The logins could not be read."]); return; } var known = roots.Select(r => r.Id).ToHashSet(); roots.AddRange(page.Items.Where(r => known.Add(r.Id))); next = page.Next; } void Toggle(ViewAdminRoot root) { if (!opened.Remove(root.Id)) opened.Add(root.Id); } static string State(ViewAdminPersona persona) => persona.SuspendedAt is not null ? "suspended" : persona.SilencedAt is not null ? "silenced" : "normal"; string RoleName(string role) => role switch { "admin" => Localizer["administrator"], "moderator" => Localizer["moderator"], _ => Localizer["user"] }; static DColor RoleColor(string role) => role switch { "admin" => DColor.Secondary, "moderator" => DColor.Info, _ => DColor.Neutral }; // the login as PrivaPub has it now, in its place; one gone (deleted) leaves the list async Task Refresh(ViewAdminRoot root) { var now = await ModerationService.Root(root.Id); roots = now is null ? [.. roots.Where(r => r.Id != root.Id)] : [.. roots.Select(r => r.Id == root.Id ? now : r)]; } async Task ChangeRole(ViewAdminRoot root, string role) { var (question, text) = role switch { "admin" => (Localizer["Make {0} an administrator?", root.UserName], Localizer["Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups."]), "moderator" => (Localizer["Make {0} a moderator?", root.UserName], Localizer["Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings."]), _ => (Localizer["Make {0} a user?", root.UserName], Localizer["It keeps its personas, and loses what the staff do."]) }; if (!await Confirm.Ask(question, text, Localizer["Change the role"], true)) { roleRenders++; return; } busy.Add(root.Id); var result = await ModerationService.SetRole(root.Id, role); busy.Remove(root.Id); if (result.IsInvalid) { roleRenders++; Toast.AddResponseError(result); return; } var changed = result.Data as ViewAdminRoot; if (changed is not null) roots = [.. roots.Select(r => r.Id == root.Id ? changed : r)]; Toast.AddResponseSuccess(Localizer["{0} is now {1}.", root.UserName, RoleName(changed?.Role ?? role)]); } async Task Ban(ViewAdminRoot root) { if (!await Confirm.Ask(Localizer["Ban {0}?", root.UserName], Localizer["Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted."], Localizer["Ban"], true)) return; await Run(root, ModerationService.Ban, Localizer["{0} is banned.", root.UserName]); } async Task Unban(ViewAdminRoot root) { if (!await Confirm.Ask(Localizer["Lift {0}'s ban?", root.UserName], Localizer["Its personas are given back as they were."], Localizer["Unban"], false)) return; await Run(root, ModerationService.Unban, Localizer["{0} is no longer banned.", root.UserName]); } async Task ResetTotp(ViewAdminRoot root) { if (!await Confirm.Ask(Localizer["Turn off {0}'s two-factor sign-in?", root.UserName], Localizer["For a login that lost its authenticator app and its recovery codes: its password and its sessions stay."], Localizer["Reset two-factor"], true)) return; await Run(root, ModerationService.ResetTotp, Localizer["{0} signs in with its password alone now.", root.UserName]); } async Task DeleteRoot(ViewAdminRoot root) { if (!await Confirm.Ask(Localizer["Delete {0}?", root.UserName], Localizer["Every persona of it goes with it, and everything public they had, here and on the servers that hold it. Its names stay taken. It cannot be undone."], Localizer["Delete for good"], true)) return; busy.Add(root.Id); var result = await ModerationService.DeleteRoot(root.Id); busy.Remove(root.Id); if (result.IsInvalid) { Toast.AddResponseError(result); return; } roots = [.. roots.Where(r => r.Id != root.Id)]; Toast.AddResponseSuccess(Localizer["{0} is deleted.", root.UserName]); } async Task Run(ViewAdminRoot root, Func> change, string done) { busy.Add(root.Id); var result = await change(root.Id, default); busy.Remove(root.Id); if (result.IsInvalid) { Toast.AddResponseError(result); return; } await Refresh(root); Toast.AddResponseSuccess(done); } string Named => actingGroup is { } group ? "@" + group.UserName : actingPersona?.DisplayName is { Length: > 0 } displayName ? displayName : "@" + actingPersona?.Handle; string Question => act switch { ModerationActions.Silence => Localizer["Silence {0}?", Named], ModerationActions.Unsilence => Localizer["Unsilence {0}?", Named], ModerationActions.Suspend => Localizer["Suspend {0}?", Named], ModerationActions.Unsuspend => Localizer["Unsuspend {0}?", Named], PersonaDelete => Localizer["Delete {0}?", Named], _ => Localizer["Delete the group {0}?", Named] }; string ActText => act switch { ModerationActions.Silence => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."], ModerationActions.Suspend => Localizer["The persona can't act or be seen here any more, and its sessions end; nothing new is told to other servers. It reads the warning once the suspension is lifted."], ModerationActions.Unsilence => Localizer["Its posts are listed again."], ModerationActions.Unsuspend => Localizer["It is given back as it was, but the sessions a suspension ended."], PersonaDelete when actingPersona?.Groups.Count > 0 => Localizer["Everything public it had goes, here and on the servers that hold it, and the {0} groups it owns with it. Its name stays taken. It cannot be undone.", actingPersona.Groups.Count], PersonaDelete => Localizer["Everything public it had goes, here and on the servers that hold it, with the groups it owns. Its name stays taken. It cannot be undone."], _ => Localizer["Its members and followers are told, and its name stays taken. It cannot be undone."] }; string ConfirmText => act switch { ModerationActions.Silence => Localizer["Silence"], ModerationActions.Unsilence => Localizer["Unsilence"], ModerationActions.Suspend => Localizer["Suspend"], ModerationActions.Unsuspend => Localizer["Unsuspend"], _ => Localizer["Delete for good"] }; void Ask(ViewAdminRoot root, ViewAdminPersona persona, ViewAdminGroup group, string what) => (actingRoot, actingPersona, actingGroup, act) = (root, persona, group, what); void CloseAct() => (actingRoot, actingPersona, actingGroup, act) = (null, null, null, null); async Task Act(ActDialog.Answer answer) { var (root, persona, group, what) = (actingRoot, actingPersona, actingGroup, act); var result = what switch { PersonaDelete => await ModerationService.DeletePersona(persona.Id, new AdminPasswordForm { Password = answer.Password, Note = answer.Note }), GroupDelete => await ModerationService.DeleteGroup(group.Id, new AdminPasswordForm { Password = answer.Password, Note = answer.Note }), _ => await ModerationService.Moderate(new ModerateAccountForm { AccountId = persona.Id, Action = what, Note = answer.Note, Warning = what is ModerationActions.Silence or ModerationActions.Suspend ? answer.Warning : null }) }; if (result.IsInvalid) return result; await Refresh(root); // one of the administrator's own personas: the session follows it if (root.IsYou && what == PersonaDelete) await CascadingState.PersonaDeleted(persona.Id); else if (root.IsYou) await CascadingState.ReloadPersonas(); Toast.AddResponseSuccess(what switch { ModerationActions.Silence => Localizer["{0} is silenced.", Named], ModerationActions.Unsilence => Localizer["{0} is no longer silenced.", Named], ModerationActions.Suspend => Localizer["{0} is suspended.", Named], ModerationActions.Unsuspend => Localizer["{0} is no longer suspended.", Named], _ => Localizer["{0} is deleted.", Named] }); return result; } }