diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor
new file mode 100644
index 0000000..54ac800
--- /dev/null
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/UserList.razor
@@ -0,0 +1,243 @@
+@page "/HSAdmin/Synapse/UserQuery"
+@using Microsoft.AspNetCore.WebUtilities
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.Homeservers.Extensions.NamedCaches
+@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters
+@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses
+@using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components
+@using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components.RoomQuery
+@inject ILogger<RoomQuery> Logger
+
+<h3>Homeserver Administration - User Query</h3>
+
+<label>Search name: </label>
+<InputText @bind-Value="SearchTerm"/><br/>
+<label>Order by: </label>
+<select @bind="OrderBy">
+ @foreach (var item in validOrderBy) {
+ <option value="@item.Key">@item.Value</option>
+ }
+</select><br/>
+<label>Ascending: </label>
+<InputCheckbox @bind-Value="Ascending"/><br/>
+<details>
+ <summary>
+ <span>Local filtering (slow)</span>
+ </summary>
+ @* <SynapseRoomQueryFilter Filter="@Filter"/> *@
+</details>
+<button class="btn btn-primary" @onclick="Search">Search</button>
+<br/>
+
+@if (Results.Count > 0) {
+ <p>Found @Results.Count rooms</p>
+ @* <details> *@
+ @* <summary>TSV data (copy/paste)</summary> *@
+ @* <pre style="font-size: 0.6em;"> *@
+ @* <table> *@
+ @* @foreach (var res in Results) { *@
+ @* <tr> *@
+ @* <td style="padding: 8px;">@res.RoomId@("\t")</td> *@
+ @* <td style="padding: 8px;">@res.CanonicalAlias@("\t")</td> *@
+ @* <td style="padding: 8px;">@res.Creator@("\t")</td> *@
+ @* <td style="padding: 8px;">@res.Name</td> *@
+ @* </tr> *@
+ @* } *@
+ @* </table> *@
+ @* </pre> *@
+ @* </details> *@
+}
+
+@foreach (var user in Results) {
+ <div class="room-list-item">
+ <p>
+ <span>@user.Name</span>
+ @if (!string.IsNullOrWhiteSpace(user.DisplayName)) {
+ <span> (@user.DisplayName)</span>
+ }
+ <br/>
+ </p>
+ <p>
+ <LinkButton OnClickAsync="@(() => Login(user))">Log in</LinkButton>
+ @* <LinkButton OnClickAsync="@(() => DeleteRoom(user))">Delete room</LinkButton> *@
+ @* <LinkButton target="_blank" href="@($"/HSAdmin/Synapse/ResyncState?roomId={user.RoomId}&via={user.RoomId.Split(':', 2)[1]}")">Resync state</LinkButton> *@
+
+ </p>
+
+ @{
+ List<string?> flags = [];
+ if (user.IsGuest == true) flags.Add("guest");
+ if (user.Admin == true) flags.Add("admin");
+ if (user.Deactivated == true) flags.Add("deactivated");
+ if (user.Erased == true) flags.Add("erased");
+ if (user.ShadowBanned == true) flags.Add("shadow banned");
+ if (user.Locked == true) flags.Add("locked");
+ if (user.Approved == true) flags.Add("approved");
+
+ if (!string.IsNullOrWhiteSpace(user.UserType)) flags.Add($"type=\"{user.UserType}\"");
+
+ flags = flags.Where(x => x != null).ToList();
+ }
+ <span>@string.Join(", ", flags)</span>
+ <br/>
+
+ <details>
+ <summary>Full result data</summary>
+ <pre>@user.ToJson(ignoreNull: true)</pre>
+ </details>
+ </div>
+}
+
+@code {
+
+ [Parameter]
+ [SupplyParameterFromQuery(Name = "order_by")]
+ public string? OrderBy { get; set; }
+
+ [Parameter]
+ [SupplyParameterFromQuery(Name = "name_search")]
+ public string? SearchTerm { get; set; }
+
+ [Parameter]
+ [SupplyParameterFromQuery(Name = "ascending")]
+ public bool Ascending { get; set; } = true;
+
+ private List<SynapseAdminUserListResult.SynapseAdminUserListResultUser> Results { get; set; } = new();
+
+ private AuthenticatedHomeserverSynapse Homeserver { get; set; } = null!;
+
+ private SynapseAdminLocalUserQueryFilter Filter { get; set; } = new();
+
+ protected override async Task OnInitializedAsync() {
+ var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true);
+ if (hs is not AuthenticatedHomeserverSynapse synapse) {
+ NavigationManager.NavigateTo("/");
+ return;
+ }
+
+ Homeserver = synapse;
+ StateHasChanged();
+ }
+
+ protected override Task OnParametersSetAsync() {
+ OrderBy ??= "name";
+
+ var execute = false;
+
+ foreach (var (key, value) in QueryHelpers.ParseQuery(new Uri(NavigationManager.Uri).Query)) {
+ switch (key) {
+ // case "RoomIdContains":
+ // Filter.RoomIdContains = value[0]!;
+ // break;
+ // case "NameContains":
+ // Filter.NameContains = value[0]!;
+ // break;
+ // case "CanonicalAliasContains":
+ // Filter.CanonicalAliasContains = value[0]!;
+ // break;
+ // case "VersionContains":
+ // Filter.VersionContains = value[0]!;
+ // break;
+ // case "CreatorContains":
+ // Filter.CreatorContains = value[0]!;
+ // break;
+ // case "EncryptionContains":
+ // Filter.EncryptionContains = value[0]!;
+ // break;
+ // case "JoinRulesContains":
+ // Filter.JoinRulesContains = value[0]!;
+ // break;
+ // case "GuestAccessContains":
+ // Filter.GuestAccessContains = value[0]!;
+ // break;
+ // case "HistoryVisibilityContains":
+ // Filter.HistoryVisibilityContains = value[0]!;
+ // break;
+ // case "Federatable":
+ // Filter.Federatable = bool.Parse(value[0]!);
+ // Filter.CheckFederation = true;
+ // break;
+ // case "Public":
+ // Filter.Public = value[0] == "true";
+ // Filter.CheckPublic = true;
+ // break;
+ // case "JoinedMembersGreaterThan":
+ // Filter.JoinedMembersGreaterThan = int.Parse(value[0]!);
+ // break;
+ // case "JoinedMembersLessThan":
+ // Filter.JoinedMembersLessThan = int.Parse(value[0]!);
+ // break;
+ // case "JoinedLocalMembersGreaterThan":
+ // Filter.JoinedLocalMembersGreaterThan = int.Parse(value[0]!);
+ // break;
+ // case "JoinedLocalMembersLessThan":
+ // Filter.JoinedLocalMembersLessThan = int.Parse(value[0]!);
+ // break;
+ // case "StateEventsGreaterThan":
+ // Filter.StateEventsGreaterThan = int.Parse(value[0]!);
+ // break;
+ // case "StateEventsLessThan":
+ // Filter.StateEventsLessThan = int.Parse(value[0]!);
+ // break;
+ case "Execute":
+ execute = true;
+ break;
+ default:
+ Console.WriteLine($"Unknown query parameter: {key}");
+ break;
+ }
+ }
+
+ if (execute)
+ _ = Search();
+
+ return Task.CompletedTask;
+ }
+
+ private async Task Search() {
+ Results.Clear();
+ var searchRooms = Homeserver.Admin.SearchUsersAsync(orderBy: OrderBy!, dir: Ascending ? "f" : "b", localFilter: Filter).GetAsyncEnumerator();
+ while (await searchRooms.MoveNextAsync()) {
+ var room = searchRooms.Current;
+
+ Results.Add(room);
+
+ if ((Results.Count <= 200 && Results.Count % 10 == 0) || Results.Count % 1000 == 0) {
+ StateHasChanged();
+ await Task.Yield();
+ await Task.Delay(1);
+ }
+ }
+
+ StateHasChanged();
+
+ StateHasChanged();
+ }
+
+ private readonly Dictionary<string, string> validOrderBy = new() {
+ { "name", "User name" },
+ { "is_guest", "Guest status" },
+ { "admin", "Admin status" },
+ { "user_type", "User type" },
+ { "deactivated", "Deactivation status" },
+ { "shadow_banned", "Shadow banned status" },
+ { "displayname", "Display name" },
+ { "avatar_url", "Avatar URL" },
+ { "creation_ts", "Creation time" },
+ { "last_seen_ts", "Last activity" },
+ };
+
+ private async Task Login(SynapseAdminUserListResult.SynapseAdminUserListResultUser user) {
+ var loginResult = await Homeserver.Admin.LoginUserAsync(user.Name, TimeSpan.FromDays(1));
+ await sessionStore.AddSession(new() {
+ AccessToken = loginResult.AccessToken,
+ DeviceId = loginResult.DeviceId,
+ UserId = loginResult.UserId,
+ Homeserver = Homeserver.ServerName,
+ Proxy = Homeserver.Proxy
+ });
+
+ }
+
+}
|