Settings: change the login's password, or delete the login

A Login section in Settings changes the root's password (the current one
given) and deletes the whole login, every persona with it, after its
password and an explicit "I understand"; the client then signs out. Both
use PrivaPub's existing /clientapi/user routes.

The e2e signs up a login of its own, changes its password and deletes it,
checking each against PrivaPub's login. Signing in now waits for either
the feed or an enabled persona chooser, so a login with one persona (which
goes on by itself) and a new persona's empty feed both sign in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 18:32:47 +02:00
1 parent 0bbab720a5
commit 228708c328
11 files changed
+258 -6

No files matched your search

+3 -1
View File
@@ -253,7 +253,9 @@ supplies:
- **Interaction policies** (GoToSocial's): a reply or boost whose target's author must approve it shows so on its card - **Interaction policies** (GoToSocial's): a reply or boost whose target's author must approve it shows so on its card
(`privapub.approval`: pending, then nothing once approved, or rejected); one the policy shuts out is refused by (`privapub.approval`: pending, then nothing once approved, or rejected); one the policy shuts out is refused by
PrivaPub (422) and shown as an error. PrivaPub (422) and shown as an error.
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the persona's profile (`ProfileEditor`: - **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
the client then signs out), the persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot, `update_credentials` as multipart, its words as written from `Account.Source`, pictures, 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,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
+2
View File
@@ -32,6 +32,8 @@ namespace decePubClient;
[JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))] [JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))] [JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(JwtUser))] [JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))] [JsonSerializable(typeof(List<ViewAvatar>))]
+76
View File
@@ -0,0 +1,76 @@
@inherits LocalizableComponentBase
@* The login itself (the root, /clientapi/user): its password changed, or the whole of it deleted, every persona with
it. Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
<div class="flex flex-col gap-4 w-full" data-account>
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["Change the password"]</b>
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-current />
<div class="flex flex-wrap gap-2">
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password"
placeholder="@Localizer["New password"]" data-account-new />
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.RepeatedPassword" Immediate="true" autocomplete="new-password"
placeholder="@Localizer["Repeat the new password"]" data-account-repeat />
</div>
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-password-save>
<i class="ion-md-key"></i><span>@Localizer["Change the password"]</span>
</DButton>
</EditForm>
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
<DataAnnotationsValidator />
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
<p class="text-sm">@Localizer["Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone."]</p>
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-delete-password />
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || !understood)" data-account-delete-confirm>
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
</DButton>
</EditForm>
</div>
@code {
[Inject] AuthService AuthService { get; set; }
UserPasswordForm password = new();
RemoveSelfForm removal = new();
bool understood;
bool isSaving;
protected override void OnInitialized() => IsLoading = false;
async Task ChangePassword()
{
isSaving = true;
var result = await AuthService.ChangePassword(password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
password = new();
Toast.AddResponseSuccess(Localizer["Password changed."]);
}
async Task Delete()
{
isSaving = true;
var result = await AuthService.DeleteAccount(removal);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await CascadingState.SignOut();
Navigation.NavigateTo("login");
}
}
+2
View File
@@ -16,6 +16,8 @@ public static class APIs
public const string POST_Login = "clientapi/user/login"; public const string POST_Login = "clientapi/user/login";
public const string GET_Logout = "clientapi/user/logout"; public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again"; public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string POST_UpdatePassword = "clientapi/user/update/password";
public const string POST_DeleteAccount = "clientapi/user/delete";
public const string GET_CurrentVersion = "clientapi/data/current-version"; public const string GET_CurrentVersion = "clientapi/data/current-version";
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";
+20
View File
@@ -60,6 +60,26 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2" data-settings-account>
<i class="ion-md-key text-xl"></i> <span>@Localizer["Login"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<AccountEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
+24
View File
@@ -1691,4 +1691,28 @@
<data name="You left {0}." xml:space="preserve"> <data name="You left {0}." xml:space="preserve">
<value>Hai lasciato {0}.</value> <value>Hai lasciato {0}.</value>
</data> </data>
<data name="Change the password" xml:space="preserve">
<value>Cambia la password</value>
</data>
<data name="Current password" xml:space="preserve">
<value>Password attuale</value>
</data>
<data name="New password" xml:space="preserve">
<value>Nuova password</value>
</data>
<data name="Repeat the new password" xml:space="preserve">
<value>Ripeti la nuova password</value>
</data>
<data name="Delete this login" xml:space="preserve">
<value>Elimina questo accesso</value>
</data>
<data name="I understand: delete this login and every persona of it" xml:space="preserve">
<value>Ho capito: elimina questo accesso e ogni sua persona</value>
</data>
<data name="Password changed." xml:space="preserve">
<value>Password cambiata.</value>
</data>
<data name="Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone." xml:space="preserve">
<value>Ogni persona di questo accesso se ne va con lui, e tutto ciò che avevano di pubblico, qui e sui server che lo tengono. Non si può annullare.</value>
</data>
</root> </root>
+24
View File
@@ -1691,4 +1691,28 @@
<data name="You left {0}." xml:space="preserve"> <data name="You left {0}." xml:space="preserve">
<value>You left {0}.</value> <value>You left {0}.</value>
</data> </data>
<data name="Change the password" xml:space="preserve">
<value>Change the password</value>
</data>
<data name="Current password" xml:space="preserve">
<value>Current password</value>
</data>
<data name="New password" xml:space="preserve">
<value>New password</value>
</data>
<data name="Repeat the new password" xml:space="preserve">
<value>Repeat the new password</value>
</data>
<data name="Delete this login" xml:space="preserve">
<value>Delete this login</value>
</data>
<data name="I understand: delete this login and every persona of it" xml:space="preserve">
<value>I understand: delete this login and every persona of it</value>
</data>
<data name="Password changed." xml:space="preserve">
<value>Password changed.</value>
</data>
<data name="Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone." xml:space="preserve">
<value>Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone.</value>
</data>
</root> </root>
+21
View File
@@ -114,6 +114,27 @@ namespace decePubClient.Services
await authStateProvider.SetSession(jwtUser, cancellationToken); await authStateProvider.SetSession(jwtUser, cancellationToken);
} }
/// <summary>Changes the root's password, the current one given.</summary>
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Deletes the root, its password given: every persona of it and everything public they had, here and, as
/// PrivaPub tells them, on the servers that hold it. The caller signs out afterwards.</summary>
public async Task<WebResult> DeleteAccount(RemoveSelfForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeleteAccount, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary> /// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
public async Task Logout(CancellationToken cancellationToken = default) public async Task Logout(CancellationToken cancellationToken = default)
{ {
+1
View File
@@ -43,4 +43,5 @@
@using PrivaPub.ClientModels @using PrivaPub.ClientModels
@using PrivaPub.ClientModels.Admin @using PrivaPub.ClientModels.Admin
@using PrivaPub.ClientModels.Group @using PrivaPub.ClientModels.Group
@using PrivaPub.ClientModels.User
@using PrivaPub.ClientModels.User.Avatar @using PrivaPub.ClientModels.User.Avatar
+67
View File
@@ -0,0 +1,67 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// A login of its own, made for the test: its password changed in Settings, then the whole of it deleted there, each
// checked against PrivaPub's own login
public sealed class AccountTests(Browser browser)
{
const string FirstPassword = "Pasture-Pass-1!";
const string SecondPassword = "Pasture-Pass-2!";
static async Task<HttpStatusCode> Login(string root, string password)
{
using var http = Pasture.Http();
using var response = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
return response.StatusCode;
}
[Fact]
public async Task A_logins_password_is_changed_and_the_login_deleted_from_settings()
{
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
var root = $"e2eroot{stamp}";
using (var http = Pasture.Http())
{
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = root, password = FirstPassword });
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
var jwt = JsonNode.Parse(await signup.Content.ReadAsStringAsync())!["token"]!.GetValue<string>();
using var insert = new HttpRequestMessage(HttpMethod.Post, Pasture.Api + "/clientapi/avatar/private/insert")
{
Content = JsonContent.Create(new { userName = $"e2eacct{stamp}", name = "an e2e persona", biography = "for the account test" })
};
insert.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
using var inserted = await http.SendAsync(insert);
Assert.True(inserted.IsSuccessStatusCode, $"the persona could not be made ({(int)inserted.StatusCode})");
}
var persona = new Pasture.Persona($"e2eacct{stamp}", "an e2e persona", root, FirstPassword, null, null, null);
await using var session = await SignIn.As(browser, persona, fresh: true);
var page = session.Page;
await Results.Cell("client.action.account", "privapub", root, async () =>
{
await page.GotoAsync($"{Pasture.Site}/settings");
await page.Locator("[data-settings-account]").ClickAsync();
await page.Locator("input[data-account-current]").FillAsync(FirstPassword);
await page.Locator("input[data-account-new]").FillAsync(SecondPassword);
await page.Locator("input[data-account-repeat]").FillAsync(SecondPassword);
await page.Locator("[data-account-password-save]").ClickAsync();
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) == HttpStatusCode.OK, 15), "the new password never worked");
Assert.NotEqual(HttpStatusCode.OK, await Login(root, FirstPassword));
await page.Locator("input[data-account-delete-password]").FillAsync(SecondPassword);
await page.Locator("input[data-account-delete-understood]").CheckAsync();
await page.Locator("[data-account-delete-confirm]").ClickAsync();
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) != HttpStatusCode.OK, 15), "the deleted login still signs in");
await session.AssertGuards();
}, page);
}
}
}
+18 -5
View File
@@ -52,12 +52,25 @@ namespace decePubClient.E2E.Support
await page.Locator("input[autocomplete=username]").FillAsync(persona.Root); await page.Locator("input[autocomplete=username]").FillAsync(persona.Root);
await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password); await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password);
await page.Locator("button[type=submit]").ClickAsync(); await page.Locator("button[type=submit]").ClickAsync();
// a root with several personas picks one; with one it goes straight on // a root with several personas picks one; one with a single persona goes on by itself, its button disabled while it
var chooser = page.Locator("section button").Filter(new() { HasText = persona.UserName + "@privapub.test" }); // does. Only the login page's chooser is clicked: the feed's persona switcher names the persona too
await chooser.Or(page.Locator("article[data-uri]")).First.WaitForAsync(new() { Timeout = 30000 }); var chooser = page.Locator("section button:enabled").Filter(new() { HasText = persona.UserName + "@privapub.test" });
if (await chooser.CountAsync() > 0) var until = DateTime.UtcNow.AddSeconds(30);
await chooser.First.ClickAsync(); while (new Uri(page.Url).AbsolutePath != "/" && DateTime.UtcNow < until)
{
if (await chooser.CountAsync() > 0)
try
{
await chooser.First.ClickAsync(new() { Timeout = 2000 });
}
catch (TimeoutException)
{
}
await page.WaitForTimeoutAsync(250);
}
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 }); await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
// (a new persona's feed is empty)
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
} }
} }
} }