diff --git a/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Home.razor b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Home.razor
new file mode 100644
index 000000000..812a61521
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Home.razor
@@ -0,0 +1,107 @@
+@page "/"
+@using System.Net.Http.Headers
+@using Spacebar.AdminAPI.TestClient.Services
+@inject Config Config
+@inject ILocalStorageService LocalStorage
+
+<PageTitle>Home</PageTitle>
+
+<span style="@($"color: {(IsApiUrlValid ? "green" : "red")};")">Spacebar API URL: </span>
+<InputText @bind-Value:get="Config.ApiUrl" @bind-Value:set="@(async (v) => {
+ Config.ApiUrl = v!;
+ await ValidateAndSaveConfig();
+ })"/>
+<br/>
+
+<!-- <span style="@($"color: {(IsGatewayUrlValid ? "green" : "red")};")">Spacebar Gateway URL: </span> -->
+<!-- <InputText @bind-Value="GatewayUrl" /> -->
+<!-- <br /> -->
+
+<span style="@($"color: {(IsCdnUrlValid ? "green" : "red")};")">Spacebar CDN URL: </span>
+<InputText @bind-Value:get="Config.CdnUrl" @bind-Value:set="@(async (v) => {
+ Config.CdnUrl = v!;
+ await ValidateAndSaveConfig();
+ })"/>
+<br/>
+
+<span style="@($"color: {(IsAdminApiUrlValid ? "green" : "red")};")">Spacebar Admin API URL: </span>
+<InputText @bind-Value:get="Config.AdminUrl" @bind-Value:set="@(async (v) => {
+ Config.AdminUrl = v!;
+ await ValidateAndSaveConfig();
+ })"/>
+<br/>
+
+<span style="@($"color: {(IsAccessTokenValid ? "green" : "red")};")">Access Token: </span>
+<InputText @bind-Value:get="Config.AccessToken" @bind-Value:set="@(async (v) => {
+ Config.AccessToken = v!;
+ await ValidateAndSaveConfig();
+ })"/>
+<a href="/login">New access token</a>
+<br/>
+
+@code {
+
+ private bool IsApiUrlValid { get; set; }
+
+ // private bool IsGatewayUrlValid { get; set; }
+ private bool IsCdnUrlValid { get; set; }
+ private bool IsAdminApiUrlValid { get; set; }
+ private bool IsAccessTokenValid { get; set; }
+
+ protected override async Task OnInitializedAsync() {
+ await ValidateAndSaveConfig();
+ }
+
+ private async Task ValidateAndSaveConfig() {
+ await LocalStorage.SetItemAsync("sb_admin_tc_config", Config);
+
+ using var hc = new HttpClient();
+ HttpResponseMessage response;
+ try {
+ response = await hc.GetAsync(Config.ApiUrl + "/api/v9/policies/instance/domains");
+ IsApiUrlValid = response.IsSuccessStatusCode;
+ }
+ catch {
+ IsApiUrlValid = false;
+ }
+
+ StateHasChanged();
+
+ // response = await hc.GetAsync(Config.GatewayUrl + "/api/v9/policies/instance");
+ // IsGatewayUrlValid = response.IsSuccessStatusCode;
+ // StateHasChanged();
+
+ try {
+ response = await hc.GetAsync(Config.CdnUrl + "/ping");
+ IsCdnUrlValid = response.IsSuccessStatusCode;
+ }
+ catch {
+ IsCdnUrlValid = false;
+ }
+
+ StateHasChanged();
+
+ try {
+ response = await hc.GetAsync(Config.AdminUrl + "/_spacebar/admin/ping");
+ IsAdminApiUrlValid = response.IsSuccessStatusCode;
+ }
+ catch {
+ IsAdminApiUrlValid = false;
+ }
+
+ StateHasChanged();
+
+ try {
+ var request = new HttpRequestMessage(HttpMethod.Get, Config.AdminUrl + "/_spacebar/admin/whoami");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Config.AccessToken);
+ response = await hc.SendAsync(request);
+ IsAccessTokenValid = response.IsSuccessStatusCode;
+ }
+ catch {
+ IsAccessTokenValid = false;
+ }
+
+ StateHasChanged();
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Login.razor b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Login.razor
new file mode 100644
index 000000000..ca1205ed8
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Login.razor
@@ -0,0 +1,57 @@
+@page "/Login"
+@using System.Text.Json.Nodes
+@using Spacebar.AdminAPI.TestClient.Services
+@inject ILocalStorageService LocalStorage
+@inject Config Config
+@inject NavigationManager Navigation
+<h3>Login</h3>
+
+<span>Email: </span>
+<InputText @bind-Value="Email"/>
+<br/>
+
+<span>Password: </span>
+<InputText type="password" @bind-Value="Password"/>
+<br/>
+
+<button @onclick="DoLogin">Login</button>
+<br/>
+
+<pre style="color: red; font-family: 'JetBrains Mono',monospace">@Error</pre>
+
+
+@code {
+ private string Email { get; set; }
+ private string Password { get; set; }
+ private string Error { get; set; }
+
+ private async Task DoLogin() {
+ HttpResponseMessage response;
+ using var hc = new HttpClient();
+
+ try {
+ response = await hc.PostAsJsonAsync(Config.ApiUrl + "/api/v9/auth/login", new {
+ login = Email,
+ password = Password,
+ login_source = "Spacebar Admin API Test Client",
+ undelete = false
+ });
+ }
+ catch (Exception e) {
+ Error = e.ToString();
+ return;
+ }
+
+ if (!response.IsSuccessStatusCode) {
+ Error = await response.Content.ReadAsStringAsync();
+ return;
+ }
+
+ var content = await response.Content.ReadFromJsonAsync<JsonObject>();
+ var accessToken = content!["token"].ToString();
+ Config.AccessToken = accessToken;
+ await LocalStorage.SetItemAsync("sb_admin_tc_config", Config);
+ Navigation.NavigateTo("/", true, true);
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Users.razor b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Users.razor
new file mode 100644
index 000000000..13c346617
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/Users.razor
@@ -0,0 +1,72 @@
+@page "/Users"
+@using System.Net.Http.Headers
+@using System.Reflection
+@using Spacebar.AdminApi.Models
+@using Spacebar.AdminAPI.TestClient.Services
+@using ArcaneLibs.Blazor.Components
+@inject Config Config
+@inject ILocalStorageService LocalStorage
+
+<PageTitle>Users</PageTitle>
+
+<details>
+ <summary>Displayed columns</summary>
+ @foreach (var column in DisplayedColumns) {
+ var value = column.Value;
+ <span>
+ <InputCheckbox @bind-Value:get="@(value)" @bind-Value:set="@(b => {
+ DisplayedColumns[column.Key] = b;
+ StateHasChanged();
+ })"/>
+ @column.Key.Name
+ </span>
+ <br/>
+ }
+</details>
+
+<table class="table table-bordered">
+ @{
+ var columns = DisplayedColumns.Where(kvp => kvp.Value).Select(kvp => kvp.Key).ToList();
+ }
+ <thead>
+ <tr>
+ @foreach (var column in columns) {
+ <th>@column.Name</th>
+ }
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var user in UserList) {
+ <tr>
+ @foreach (var column in columns) {
+ <td>@column.GetValue(user)</td>
+ }
+ <td>
+ <LinkButton href="@($"/Users/Delete/{user.Id}")" Color="#ff0000">Delete</LinkButton>
+ </td>
+ </tr>
+ }
+ </tbody>
+</table>
+
+@code {
+
+ private Dictionary<PropertyInfo, bool> DisplayedColumns { get; set; } = typeof(UserModel).GetProperties()
+ .ToDictionary(p => p, p => p.Name == "Username" || p.Name == "Id" || p.Name == "MessageCount");
+
+ private List<UserModel> UserList { get; set; } = new();
+
+ protected override async Task OnInitializedAsync() {
+ using var hc = new HttpClient();
+ hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Config.AccessToken);
+ var response = await hc.GetAsync(Config.AdminUrl + "/_spacebar/admin/users/");
+ if (!response.IsSuccessStatusCode) throw new Exception(await response.Content.ReadAsStringAsync());
+ var content = response.Content.ReadFromJsonAsAsyncEnumerable<UserModel>();
+ await foreach (var user in content) {
+ UserList.Add(user);
+ StateHasChanged();
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/UsersDelete.razor b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/UsersDelete.razor
new file mode 100644
index 000000000..084a372fe
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.AdminAPI.TestClient/Pages/UsersDelete.razor
@@ -0,0 +1,61 @@
+@page "/Users/Delete/{Id}"
+@using System.Net.Http.Headers
+@using System.Text.Json
+@using System.Text.Json.Nodes
+@using ArcaneLibs.Extensions
+@using Spacebar.AdminApi.Models
+@using Spacebar.AdminAPI.TestClient.Services
+@inject Config Config
+<h3>UsersDelete - @Id</h3>
+
+Deleted @ChannelDeleteProgress.Sum(x=>x.Value.Deleted) messages so far!
+@foreach (var (channel, progress) in ChannelDeleteProgress.Where(x=>x.Value.Deleted != x.Value.Total).OrderByDescending(x=>x.Value.Progress)) {
+ <div>@channel: @progress.Total total, @progress.Deleted deleted</div>
+ <progress max="@progress.Total" value="@progress.Deleted"></progress>
+}
+
+@code {
+
+ [Parameter]
+ public required string Id { get; set; }
+
+ private Dictionary<string, DeleteProgress> ChannelDeleteProgress { get; set; } = new();
+
+ protected override async Task OnInitializedAsync() {
+ using var hc = new HttpClient();
+ hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Config.AccessToken);
+ var response = await hc.GetAsync(Config.AdminUrl + $"/_spacebar/admin/Users/{Id}/delete?messageDeleteChunkSize=100");
+ if (!response.IsSuccessStatusCode) throw new Exception(await response.Content.ReadAsStringAsync());
+ var content = response.Content.ReadFromJsonAsAsyncEnumerable<AsyncActionResult>();
+ await foreach (var actionResult in content) {
+ Console.WriteLine(actionResult.ToJson(indent: false));
+ switch (actionResult.MessageType) {
+ case "STATS": {
+ var data = JsonSerializer.Deserialize<JsonObject>(actionResult.Data.ToJson());
+ ChannelDeleteProgress = data!["messages_per_channel"]!
+ .Deserialize<Dictionary<string, int>>()!
+ .ToDictionary(x=>x.Key, x=>new DeleteProgress { Total = x.Value });
+ break;
+ }
+ case "BULK_DELETED": {
+ var data = JsonSerializer.Deserialize<JsonObject>(actionResult.Data.ToJson());
+ ChannelDeleteProgress[data!["channel_id"]!.ToString()].Deleted += data!["deleted"]!.GetValue<int>();
+ break;
+ }
+ default: {
+ Console.WriteLine($"Unknown message type: {actionResult.MessageType}");
+ break;
+ }
+ }
+
+ StateHasChanged();
+ await Task.Delay(1);
+ }
+ }
+
+ private class DeleteProgress {
+ public int Total { get; set; }
+ public int Deleted { get; set; } = 0;
+ public float Progress => (float)Deleted / Total;
+ }
+}
\ No newline at end of file
|