diff --git a/CLAUDE.md b/CLAUDE.md index 22ef093..a7cbba9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (`privapub.approval`: pending, then nothing once approved, or rejected); one the policy shuts out is refused by 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, 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 diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 0979300..d4b14d2 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -32,6 +32,8 @@ namespace decePubClient; [JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(WebResult))] [JsonSerializable(typeof(LoginForm))] +[JsonSerializable(typeof(UserPasswordForm))] +[JsonSerializable(typeof(RemoveSelfForm))] [JsonSerializable(typeof(JwtUser))] [JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(List))] diff --git a/Components/Settings/AccountEditor.razor b/Components/Settings/AccountEditor.razor new file mode 100644 index 0000000..3b983d1 --- /dev/null +++ b/Components/Settings/AccountEditor.razor @@ -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. *@ +
+ + + @Localizer["Change the password"] + +
+ + +
+ + + @Localizer["Change the password"] + +
+ + + + @Localizer["Delete this login"] +

@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."]

+ + + + + @Localizer["Delete it for good"] + +
+
+ +@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"); + } +} diff --git a/Models/APIs.cs b/Models/APIs.cs index 2997034..fe2376e 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -16,6 +16,8 @@ public static class APIs public const string POST_Login = "clientapi/user/login"; public const string GET_Logout = "clientapi/user/logout"; 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_Personas = "clientapi/avatar/private/list"; public const string POST_InsertPersona = "clientapi/avatar/private/insert"; diff --git a/Pages/Settings.razor b/Pages/Settings.razor index 8b68b13..ba66625 100644 --- a/Pages/Settings.razor +++ b/Pages/Settings.razor @@ -60,6 +60,26 @@ + +
+

+ @Localizer["Login"] +

+
+ +
+ @if (CascadingState.IsSignedIn) + { + + } + else + { + @Localizer["Sign in to PrivaPub"] + } +
+
+
+

diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index ba40171..7fbb3a1 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1691,4 +1691,28 @@ Hai lasciato {0}. + + Cambia la password + + + Password attuale + + + Nuova password + + + Ripeti la nuova password + + + Elimina questo accesso + + + Ho capito: elimina questo accesso e ogni sua persona + + + Password cambiata. + + + 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. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 3614ed2..701d0fe 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1691,4 +1691,28 @@ You left {0}. + + Change the password + + + Current password + + + New password + + + Repeat the new password + + + Delete this login + + + I understand: delete this login and every persona of it + + + Password changed. + + + 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. + diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 65faa71..5b49da8 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -114,6 +114,27 @@ namespace decePubClient.Services await authStateProvider.SetSession(jwtUser, cancellationToken); } + ///

Changes the root's password, the current one given. + public async Task 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(); + } + + /// 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. + public async Task 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(); + } + /// Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data. public async Task Logout(CancellationToken cancellationToken = default) { diff --git a/_Imports.razor b/_Imports.razor index 76ed04c..21b7bcf 100644 --- a/_Imports.razor +++ b/_Imports.razor @@ -43,4 +43,5 @@ @using PrivaPub.ClientModels @using PrivaPub.ClientModels.Admin @using PrivaPub.ClientModels.Group +@using PrivaPub.ClientModels.User @using PrivaPub.ClientModels.User.Avatar diff --git a/tests/e2e/Actions/AccountTests.cs b/tests/e2e/Actions/AccountTests.cs new file mode 100644 index 0000000..e77ba1a --- /dev/null +++ b/tests/e2e/Actions/AccountTests.cs @@ -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 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(); + 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); + } + } +} diff --git a/tests/e2e/Support/SignIn.cs b/tests/e2e/Support/SignIn.cs index 3de3c70..28b7c8c 100644 --- a/tests/e2e/Support/SignIn.cs +++ b/tests/e2e/Support/SignIn.cs @@ -52,12 +52,25 @@ namespace decePubClient.E2E.Support await page.Locator("input[autocomplete=username]").FillAsync(persona.Root); await page.Locator("input[autocomplete=current-password]").FillAsync(persona.Password); await page.Locator("button[type=submit]").ClickAsync(); - // a root with several personas picks one; with one it goes straight on - var chooser = page.Locator("section button").Filter(new() { HasText = persona.UserName + "@privapub.test" }); - await chooser.Or(page.Locator("article[data-uri]")).First.WaitForAsync(new() { Timeout = 30000 }); - if (await chooser.CountAsync() > 0) - await chooser.First.ClickAsync(); + // a root with several personas picks one; one with a single persona goes on by itself, its button disabled while it + // does. Only the login page's chooser is clicked: the feed's persona switcher names the persona too + var chooser = page.Locator("section button:enabled").Filter(new() { HasText = persona.UserName + "@privapub.test" }); + var until = DateTime.UtcNow.AddSeconds(30); + 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 }); + // (a new persona's feed is empty) + await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); } } }