@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"]
@(group.Name ?? group.UserName)@("@" + group.UserName) @group.MembersCount@Localizer["Delete the group"]
}
}
}
}
@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;
}
}