diff --git a/CLAUDE.md b/CLAUDE.md index ea6d6d9..36b26a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,14 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, ``) is lo - picking a hue leaves grey. - `AppStatusService.IsDarkTheme()` tells which variant is showing. - Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones. - The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring). + The other sections of Settings belong to the login or the persona (see **Settings** under PrivaPub wiring). +- **The theme is the login's** while `PageSettings.SyncWithLogin` is on (the default; Settings → General turns it off + for this device). `CascadingState` takes the login's theme (`ViewUserSettings`; never its language, which would reload + the app) at sign-in from the `JwtUser` (`SignedIn`, which Login, Setup and Join call instead of `LoadSession`) and at + start-up from `/clientapi/user/settings`, once `savedAt` says a client saved one; while none did, it gives the login + this device's. `UpdatePageSettings` saves them to `/clientapi/user/update/settings` 1.5 s after the last change, one + save waiting at a time, only when they differ from what the login is known to keep (so taking them never sends them + back), with the language as two letters and the icons' hue at 25; a failure is logged, never shown. - `Styles/neo.css` holds the neumorphic layer: - `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the old `neomorph is-nxsmall` classes); @@ -232,8 +239,8 @@ Never redeclare the base's members in a component. `SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload can be an object (JSON), name/value pairs (a form) or a `Func` (multipart), built once per attempt. - Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses - them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: - PrivaPub redirects it). + them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must + not carry a JWT: PrivaPub redirects them). - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the `HttpClient`'s own is off. @@ -250,7 +257,13 @@ Never redeclare the base's members in a component. - **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`, RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the - persona tokens; logout revokes every persona token, then forgets the session. + persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then + forgets the session. + - **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every + session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets + its own and signs out. A password change ends every other session and revokes every persona token, this device's + too: `AuthService.ChangePassword` keeps the new `JwtUser` PrivaPub answers with, drops the stored persona tokens and + exchanges one for the current persona at once (the others when next used), so the device stays signed in. - **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16): - **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first login is made there, first come, first served, with its first persona. It becomes the administrator, and setup @@ -259,6 +272,13 @@ Never redeclare the base's members in a component. the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code. - Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a persona is sent from Login to Settings to make one. + - **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's + name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the + same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified + address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar + and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new + password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in. + All of it goes with no credentials. - **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, @@ -338,9 +358,13 @@ Never redeclare the base's members in a component. - **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): 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 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`: +- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address + (`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`, + then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address + waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as + such; its password changed; every session of it ended ("Sign out everywhere", see **One sign-in**); 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 (streamed from the file, and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot, discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 0ba83e2..a4ae438 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -35,6 +35,16 @@ namespace decePubClient; [JsonSerializable(typeof(ViewServerSetup))] [JsonSerializable(typeof(UserPasswordForm))] [JsonSerializable(typeof(RemoveSelfForm))] +[JsonSerializable(typeof(PasswordRecoveryForm))] +[JsonSerializable(typeof(NewPasswordForm))] +[JsonSerializable(typeof(ViewAccount))] +[JsonSerializable(typeof(EmailChangeForm))] +[JsonSerializable(typeof(EmailCodeForm))] +[JsonSerializable(typeof(PasswordForm))] +[JsonSerializable(typeof(ViewUserSettings))] +// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] [JsonSerializable(typeof(JwtUser))] [JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(List))] diff --git a/Components/Settings/AccountEditor.razor b/Components/Settings/AccountEditor.razor index 3b983d1..4e5a804 100644 --- a/Components/Settings/AccountEditor.razor +++ b/Components/Settings/AccountEditor.razor @@ -1,11 +1,111 @@ @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. *@ +@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the + new one, since a forgotten password is recovered through it; its password changed (every other session ends, this + device's goes on with a new JWT); every session of it ended at once; 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["Email address"] + @if (IsLoading) + { + + } + else if (account is not null) + { + @if (account.Email is { Length: > 0 } email) + { +

+ @email + @if (account.IsEmailValidated) + { + @Localizer["verified"] + } + else + { + @Localizer["not verified"] + } +

+ @if (!account.IsEmailValidated) + { +

@Localizer["Only a verified address can recover a forgotten password."]

+ } + } + else + { +

@Localizer["No email address yet: without one, a forgotten password can't be recovered."]

+ } + + @if (account.PendingEmail is { Length: > 0 } pending && !isChangingEmail) + { + + + @if (codeSentence is not null) + { +

@codeSentence

+ } +

@Localizer["Enter the code sent to {0}.", pending]

+ + + + @Localizer["Verify"] + +
+ } + + @if (isChangingEmail) + { + + + + + +
+ @Localizer["Cancel"] + + @Localizer["Send the code"] + +
+
+ } + else if (isRemovingEmail) + { + + + + +
+ @Localizer["Cancel"] + + @Localizer["Remove the address"] + +
+
+ } + else + { +
+ @if (account.Email is { Length: > 0 }) + { + + @Localizer["Remove the address"] + + } + + @(account.Email is { Length: > 0 } ? Localizer["Change"] : Localizer["Add an address"]) + +
+ } + } +
+ @Localizer["Change the password"] +

@Localizer["Every other device signed in to this login is signed out."]

@@ -20,6 +120,14 @@ +
+ @Localizer["Sign out everywhere"] +

@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]

+ + @Localizer["Sign out everywhere"] + +
+ @Localizer["Delete this login"] @@ -36,17 +144,125 @@ @code { [Inject] AuthService AuthService { get; set; } + [Inject] ConfirmService Confirm { get; set; } + /// The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty). + ViewAccount account; + EmailChangeForm change = new(); + EmailCodeForm code = new(); + PasswordForm emailRemoval = new(); UserPasswordForm password = new(); RemoveSelfForm removal = new(); + /// PrivaPub's answer to the change asked here, the same whether or not the address is free. + string codeSentence; + bool isChangingEmail; + bool isRemovingEmail; bool understood; bool isSaving; - protected override void OnInitialized() => IsLoading = false; + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => account = await AuthService.Account(); + + void OpenEmailChange() + { + change = new(); + isRemovingEmail = false; + isChangingEmail = true; + } + + void OpenEmailRemoval() + { + emailRemoval = new(); + isChangingEmail = false; + isRemovingEmail = true; + } + + void CloseEmailForms() + { + isChangingEmail = false; + isRemovingEmail = false; + } + + async Task ChangeEmail() + { + isSaving = true; + change.Email = change.Email.Trim(); + var result = await AuthService.ChangeEmail(change); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + codeSentence = result.Data is string { Length: > 0 } said + ? said + : Localizer["If that address can be used, a code is on its way to it. It works for one hour."]; + // as PrivaPub keeps it while it waits for its code + account.PendingEmail = change.Email.ToLowerInvariant(); + change = new(); + code = new(); + isChangingEmail = false; + } + + async Task VerifyEmail() + { + isSaving = true; + var result = await AuthService.VerifyEmail(code.Code.Trim()); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + // five wrong codes, or an hour, and the address no longer waits + account = await AuthService.Account() ?? account; + return; + } + + account = result.Data as ViewAccount ?? await AuthService.Account(); + code = new(); + codeSentence = default; + Toast.AddResponseSuccess(Localizer["Email address verified."]); + } + + async Task RemoveEmail() + { + if (!await Confirm.Ask(Localizer["Remove the email address?"], Localizer["Without one, a forgotten password can't be recovered."], + Localizer["Remove the address"], true)) + return; + + isSaving = true; + var result = await AuthService.RemoveEmail(emailRemoval.Password); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + account = result.Data as ViewAccount ?? await AuthService.Account(); + emailRemoval = new(); + codeSentence = default; + isRemovingEmail = false; + Toast.AddResponseSuccess(Localizer["Email address removed."]); + } async Task ChangePassword() { isSaving = true; + // the session goes on with the JWT PrivaPub answers with, and the persona with a new token var result = await AuthService.ChangePassword(password); isSaving = false; if (result.IsInvalid) @@ -59,6 +275,26 @@ Toast.AddResponseSuccess(Localizer["Password changed."]); } + async Task LogoutEverywhere() + { + if (!await Confirm.Ask(Localizer["Sign out everywhere?"], + Localizer["Every device and every persona of this login signs out, this one too. Signing in again takes the password."], + Localizer["Sign out everywhere"], true)) + return; + + isSaving = true; + var result = await AuthService.LogoutEverywhere(); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + await CascadingState.SignOut(); + Navigation.NavigateTo("login"); + } + async Task Delete() { isSaving = true; diff --git a/LayerComponents/CascadingState.razor b/LayerComponents/CascadingState.razor index 89360e6..7ae6e78 100644 --- a/LayerComponents/CascadingState.razor +++ b/LayerComponents/CascadingState.razor @@ -3,7 +3,8 @@ @* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages - compare PersonaId to reload what belongs to the persona. *@ + compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's: + taken at sign-in and at start-up, saved there a moment after it changes here. *@ @ChildContent @@ -11,6 +12,8 @@ @code { /// How often the JWT's expiry is checked; AuthService renews it two days before. static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1); + /// How long a change of the page settings waits before it is saved as the login's: a slider sends many. + static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500); [Parameter] public RenderFragment ChildContent { get; set; } [Inject] ILocalStorageService Storage { get; set; } @@ -42,6 +45,11 @@ PeriodicTimer onlineTimer; PeriodicTimer refreshTimer; readonly CancellationTokenSource disposeCancellation = new(); + /// What the login is known to keep, as LoginFingerprint reads it (null when unknown), so settings that already + /// match it, the login's own just taken among them, aren't saved again. + (string, short, short, bool, bool, bool, bool)? loginSettings; + /// The one save of the page settings waiting for SettingsSaveDelay to pass. + CancellationTokenSource pendingSettingsSave; protected override async Task OnInitializedAsync() { @@ -59,6 +67,8 @@ _ = PollOnline(cancellationToken); await LoadSession(cancellationToken); + if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin) + await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken); refreshTimer = new(RefreshInterval); _ = KeepSessionFresh(cancellationToken); } @@ -77,13 +87,98 @@ StateHasChanged(); } - /// Changes the page settings, then saves and applies them like UpdatePublicCache. + /// Changes the page settings, then saves and applies them like UpdatePublicCache, and saves them as the + /// login's a moment later (SaveSettingsSoon). public ValueTask UpdatePageSettings(Action change, CancellationToken cancellationToken = default) { change(PublicCacheData.PageSettings); + SaveSettingsSoon(); return UpdatePublicCache(PublicCacheData, cancellationToken); } + /// + /// Reads the session a login, a setup or an invitation has just started (AuthService keeps its JWT, which carries the + /// login's settings), then keeps the theme with the login's (KeepThemeWithLogin). + /// + public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default) + { + loginSettings = null; + await LoadSession(cancellationToken); + await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken); + } + + /// + /// When this device keeps its theme with the login: takes the login's theme (never its language, which would reload the + /// app) once a client saved one, or gives the login this device's while none did. Unknown settings change nothing. + /// + async Task KeepThemeWithLogin(ViewUserSettings settings, CancellationToken cancellationToken) + { + if (settings is null || !IsSignedIn || !PublicCacheData.PageSettings.SyncWithLogin) + return; + + if (settings.SavedAt is null) + { + SaveSettingsSoon(); + return; + } + + PublicCacheData.PageSettings.TakeLoginTheme(settings); + // as the login keeps them now, so taking them isn't followed by saving them back + loginSettings = LoginFingerprint(PublicCacheData.PageSettings.ToLoginSettings()); + await UpdatePublicCache(PublicCacheData, cancellationToken); + } + + /// + /// Saves the page settings as the login's once SettingsSaveDelay passed without another change: signed in, with + /// SyncWithLogin, and only when they differ from what the login is known to keep. One save waits at a time; a failure + /// is logged (AuthService.SaveSettings), never shown. + /// + void SaveSettingsSoon() + { + CancelSettingsSave(); + var settings = PublicCacheData.PageSettings; + if (!IsSignedIn || !settings.SyncWithLogin || LoginFingerprint(settings.ToLoginSettings()) == loginSettings) + return; + + pendingSettingsSave = new(); + _ = SaveSettingsLater(pendingSettingsSave.Token); + } + + async Task SaveSettingsLater(CancellationToken cancellationToken) + { + try + { + await Task.Delay(SettingsSaveDelay, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + + if (!IsSignedIn) + return; + + var settings = PublicCacheData.PageSettings.ToLoginSettings(); + var result = await AuthService.SaveSettings(settings, cancellationToken); + if (result.IsInvalid) + return; + + loginSettings = LoginFingerprint(settings); + } + + void CancelSettingsSave() + { + var pending = pendingSettingsSave; + pendingSettingsSave = null; + pending?.Cancel(); + pending?.Dispose(); + } + + /// What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved. + static (string, short, short, bool, bool, bool, bool) LoginFingerprint(ViewUserSettings settings) => + (settings.LanguageCode, settings.LightThemeIndexColour, settings.DarkThemeIndexColour, settings.PreferSystemTheming, + settings.ThemeIsDarkMode, settings.ThemeIsLightGray, settings.ThemeIsDarkGray); + /// /// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and /// acts as the last one used (the first one otherwise). @@ -161,6 +256,7 @@ /// Revokes the persona tokens, ends the session and forgets the local data. public async Task SignOut(CancellationToken cancellationToken = default) { + CancelSettingsSave(); await AuthService.Logout(cancellationToken); ClearSession(); StateHasChanged(); @@ -168,6 +264,8 @@ void ClearSession() { + CancelSettingsSave(); + loginSettings = null; IsSignedIn = false; RootUserName = null; Personas = []; @@ -236,6 +334,7 @@ { await disposeCancellation.CancelAsync(); disposeCancellation.Dispose(); + CancelSettingsSave(); onlineTimer?.Dispose(); refreshTimer?.Dispose(); cascadingStateReference?.Dispose(); diff --git a/Models/APIs.cs b/Models/APIs.cs index d6bcd42..482cad6 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -19,6 +19,16 @@ public static class APIs 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_LogoutEverywhere = "clientapi/user/logout/everywhere"; + public const string POST_RecoverPassword = "clientapi/user/recover/password"; + public const string POST_RecoverValid = "clientapi/user/recover/valid"; + public const string POST_RecoverUpdatePassword = "clientapi/user/recover/update/password"; + public const string GET_Account = "clientapi/user/account"; + public const string POST_Email = "clientapi/user/email"; + public const string POST_EmailVerify = "clientapi/user/email/verify"; + public const string POST_EmailRemove = "clientapi/user/email/remove"; + public const string GET_UserSettings = "clientapi/user/settings"; + public const string POST_UpdateUserSettings = "clientapi/user/update/settings"; public const string POST_DeleteAccount = "clientapi/user/delete"; public const string GET_Personas = "clientapi/avatar/private/list"; public const string POST_InsertPersona = "clientapi/avatar/private/insert"; diff --git a/Models/PageSettings.cs b/Models/PageSettings.cs index 1164cf8..64dd63b 100644 --- a/Models/PageSettings.cs +++ b/Models/PageSettings.cs @@ -1,5 +1,6 @@  using PrivaPub.ClientModels.Resources; +using PrivaPub.ClientModels.User; using System.ComponentModel.DataAnnotations; @@ -21,6 +22,8 @@ namespace decePubClient.Models public bool ThemeIsDarkGray { get; set; } = false; // before publishing pictures without a description, the composer asks once public bool RemindAltText { get; set; } = true; + // the theme is the login's (PrivaPub's user settings): taken at sign-in, saved there whenever it changes here + public bool SyncWithLogin { get; set; } = true; // How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark; // otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the @@ -56,5 +59,33 @@ namespace decePubClient.Models LightThemeIndexColour = hue; SetGray(false, isDark); } + + /// + /// These settings as the login keeps them: the language as the two letters of the culture the app runs in + /// (ExtensionMethods.ToCulture: "it" is it-IT, anything else en-GB), the icons' hue at its default, since the client + /// has no such setting. + /// + public ViewUserSettings ToLoginSettings() => new() + { + LanguageCode = CurrentLanguageCode is "it" ? "it" : "en", + LightThemeIndexColour = LightThemeIndexColour, + DarkThemeIndexColour = DarkThemeIndexColour, + IconsThemeIndexColour = DefaultLightThemeIndexColour, + PreferSystemTheming = PreferSystemTheming, + ThemeIsDarkMode = ThemeIsDarkMode, + ThemeIsLightGray = ThemeIsLightGray, + ThemeIsDarkGray = ThemeIsDarkGray + }; + + /// Takes the login's theme; its language stays this device's, since changing it reloads the app. + public void TakeLoginTheme(ViewUserSettings settings) + { + LightThemeIndexColour = Math.Clamp(settings.LightThemeIndexColour, (short)0, (short)359); + DarkThemeIndexColour = Math.Clamp(settings.DarkThemeIndexColour, (short)0, (short)359); + PreferSystemTheming = settings.PreferSystemTheming; + ThemeIsDarkMode = settings.ThemeIsDarkMode; + ThemeIsLightGray = settings.ThemeIsLightGray; + ThemeIsDarkGray = settings.ThemeIsDarkGray; + } } } \ No newline at end of file diff --git a/Pages/Join.razor b/Pages/Join.razor index d8bcfe7..9361143 100644 --- a/Pages/Join.razor +++ b/Pages/Join.razor @@ -115,7 +115,7 @@ return; } - await CascadingState.LoadSession(); + await CascadingState.SignedIn(result.Data as JwtUser); isWorking = false; Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]); Navigation.NavigateTo(""); diff --git a/Pages/Login.razor b/Pages/Login.razor index 77fc197..a09b4ed 100644 --- a/Pages/Login.razor +++ b/Pages/Login.razor @@ -4,7 +4,8 @@ @* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers - its setup (Setup); anyone else joins through a group's invitation (Join). *@ + its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through + the login's verified address (PasswordRecovery). *@
@@ -49,6 +50,7 @@ } @Localizer["Login"] + @Localizer["Forgot your password?"]

@Localizer["Have an invitation?"] @Localizer["Join with an invitation"] @@ -130,7 +132,7 @@ return; } - await CascadingState.LoadSession(); + await CascadingState.SignedIn(result.Data as JwtUser); isWorking = false; if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null) Continue(); diff --git a/Pages/PasswordRecovery.razor b/Pages/PasswordRecovery.razor new file mode 100644 index 0000000..e63facf --- /dev/null +++ b/Pages/PasswordRecovery.razor @@ -0,0 +1,216 @@ +@page "/password-recovery" +@inherits LocalizableComponentBase +@Localizer["Forgot your password?"] + +@* A forgotten password (PrivaPub's /clientapi/user/recover): the login's name, or its verified address, asks for a link, + and PrivaPub answers everyone with the same sentence, so the page never tells whether a login or an address exists. + The link comes back here with its code (?rc=), which leaves the address bar and the history at once and stays in the + page; a new password set with it ends every session the login had, and the reader signs in with it. *@ +

+ +
+ + @if (CascadingState.IsSignedIn) + { +
+

@Localizer["You are signed in"]

+ @Localizer["Home"] +
+ } + else if (recoveryCode is null && sentence is not null) + { +
+

@Localizer["Check your inbox"]

+

@sentence

+ @Localizer["Login"] +
+ } + else if (recoveryCode is null) + { + + +

@Localizer["Forgot your password?"]

+

@Localizer["Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it."]

+ + + @Localizer["or"] + + + + + + @if (isWorking) + { + + } + @Localizer["Send the recovery link"] + + @Localizer["I remember it: sign in"] +
+ } + else if (IsLoading) + { + + } + else if (isCodeValid is false) + { +
+

@Localizer["This recovery link doesn't work"]

+

@Localizer["A link works for one hour, and only once. Ask for a new one."]

+ @Localizer["Ask for a new link"] +
+ } + else + { + @* also while the code couldn't be checked (offline, or the check failed): the answer to the new password says *@ + + +

@Localizer["Choose a new password"]

+

@Localizer["Every device signed in to this login is signed out."]

+ + + + + + + + @if (isWorking) + { + + } + @Localizer["Change the password"] + +
+ } + +
+ +
+ +@code { + [Inject] AuthService AuthService { get; set; } + + readonly PasswordRecoveryForm request = new(); + NewPasswordForm newPassword = new(); + /// The code of the link the page was opened from, kept here once it left the address bar. + string recoveryCode; + /// Whether the code works: null until PrivaPub said. + bool? isCodeValid; + /// PrivaPub's answer to a request, the same for everyone. + string sentence; + string error; + bool isWorking; + + [SupplyParameterFromQuery(Name = "rc")] public string LinkCode { get; set; } + + protected override async Task OnInitializedAsync() + { + if (LinkCode is { Length: > 0 } code) + { + recoveryCode = code.Trim(); + newPassword.RecoveryCode = recoveryCode; + // the code is as good as the password for an hour: out of the address bar and the history at once + Navigation.NavigateTo("password-recovery", replace: true); + } + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + if (recoveryCode is null) + return; + + var result = await AuthService.CheckRecoveryCode(recoveryCode); + if (result.IsInvalid) + { + error = result.ErrorMessage; + return; + } + + isCodeValid = result.Data is true; + } + + static string NullIfBlank(string value) => string.IsNullOrWhiteSpace(value) ? null : value; + + async Task Request() + { + error = default; + isWorking = true; + request.UserName = request.UserName?.Trim(); + request.Email = request.Email?.Trim().ToLowerInvariant(); + var result = await AuthService.RequestRecovery(request); + isWorking = false; + if (result.IsInvalid) + { + error = result.ErrorMessage; + return; + } + + sentence = result.Data is string { Length: > 0 } said + ? said + : Localizer["If that account has an email address, a recovery link is on its way. It works for one hour."]; + } + + void AskAgain() + { + recoveryCode = null; + isCodeValid = null; + newPassword = new(); + error = default; + } + + async Task Recover() + { + error = default; + isWorking = true; + var result = await AuthService.Recover(newPassword); + isWorking = false; + if (result.StatusCode is (int)System.Net.HttpStatusCode.NotFound) + { + // used or expired meanwhile + isCodeValid = false; + return; + } + + if (result.IsInvalid) + { + error = result.ErrorMessage; + return; + } + + Toast.AddResponseSuccess(Localizer["Password changed: sign in with the new one."]); + Navigation.NavigateTo("login"); + } +} diff --git a/Pages/Settings.razor b/Pages/Settings.razor index d461402..02db043 100644 --- a/Pages/Settings.razor +++ b/Pages/Settings.razor @@ -21,6 +21,10 @@ ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" /> + @* The theme is the login's: taken at sign-in, saved to it as it changes here (CascadingState.SaveSettingsSoon). *@ + diff --git a/Pages/Setup.razor b/Pages/Setup.razor index bc21a0a..ced5463 100644 --- a/Pages/Setup.razor +++ b/Pages/Setup.razor @@ -129,7 +129,7 @@ return; } - await CascadingState.LoadSession(); + await CascadingState.SignedIn(result.Data as JwtUser); isWorking = false; Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]); Navigation.NavigateTo(""); diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index cc75f6d..365b545 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -2285,4 +2285,118 @@ Link d'invito, per chi non ha un accesso qui + + Hai dimenticato la password? + + + Controlla la posta in arrivo + + + Indica il nome utente del tuo accesso o il suo indirizzo email: se l'accesso ha un indirizzo verificato, a quell'indirizzo arriva un link per scegliere una nuova password. + + + oppure + + + Indirizzo email + + + Invia il link di recupero + + + Me la ricordo: accedi + + + Questo link di recupero non funziona + + + Un link funziona per un'ora, e una volta sola. Chiedine uno nuovo. + + + Chiedi un nuovo link + + + Scegli una nuova password + + + Ogni dispositivo collegato a questo accesso viene disconnesso. + + + Se quell'accesso ha un indirizzo email, un link di recupero è in arrivo. Funziona per un'ora. + + + Password cambiata: accedi con quella nuova. + + + verificato + + + non verificato + + + Solo un indirizzo verificato permette di recuperare una password dimenticata. + + + Ancora nessun indirizzo email: senza, una password dimenticata non si può recuperare. + + + Inserisci il codice inviato a {0}. + + + Codice + + + Verifica + + + Nuovo indirizzo email + + + Invia il codice + + + Togli l'indirizzo + + + Cambia + + + Aggiungi un indirizzo + + + Ogni altro dispositivo collegato a questo accesso viene disconnesso. + + + Esci ovunque + + + Si esce da questo accesso su ogni dispositivo, anche questo, con ogni persona: per un dispositivo perso, o una sessione che non è tua. + + + Se quell'indirizzo si può usare, un codice è in arrivo. Funziona per un'ora. + + + Indirizzo email verificato. + + + Togliere l'indirizzo email? + + + Senza, una password dimenticata non si può recuperare. + + + Indirizzo email tolto. + + + Uscire ovunque? + + + Ogni dispositivo e ogni persona di questo accesso escono, anche questo. Per rientrare serve la password. + + + Password errata. + + + Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index bee25a3..9a342b4 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -2285,4 +2285,118 @@ Invitation link, for someone without a login here + + Forgot your password? + + + Check your inbox + + + Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it. + + + or + + + Email address + + + Send the recovery link + + + I remember it: sign in + + + This recovery link doesn't work + + + A link works for one hour, and only once. Ask for a new one. + + + Ask for a new link + + + Choose a new password + + + Every device signed in to this login is signed out. + + + If that account has an email address, a recovery link is on its way. It works for one hour. + + + Password changed: sign in with the new one. + + + verified + + + not verified + + + Only a verified address can recover a forgotten password. + + + No email address yet: without one, a forgotten password can't be recovered. + + + Enter the code sent to {0}. + + + Code + + + Verify + + + New email address + + + Send the code + + + Remove the address + + + Change + + + Add an address + + + Every other device signed in to this login is signed out. + + + Sign out everywhere + + + Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours. + + + If that address can be used, a code is on its way to it. It works for one hour. + + + Email address verified. + + + Remove the email address? + + + Without one, a forgotten password can't be recovered. + + + Email address removed. + + + Sign out everywhere? + + + Every device and every persona of this login signs out, this one too. Signing in again takes the password. + + + Wrong password. + + + Keep the theme the same on every device signed in to this login + diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 2966587..50a1e6a 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; using decePubClient.Extensions; using decePubClient.Helpers; @@ -145,13 +146,155 @@ namespace decePubClient.Services await authStateProvider.SetSession(jwtUser, cancellationToken); } - /// Changes the root's password, the current one given. + /// + /// Changes the root's password, the current one given. PrivaPub ends every session of the login with it, and revokes + /// every persona's token, this device's too; it answers with a new JWT, so the session goes on with it and the current + /// persona gets a new token at once (the others when they are next used). + /// 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); + // a server from before sessions answered with nothing, and ended none + var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); + if (string.IsNullOrEmpty(jwtUser?.Token)) + return new WebResult(); + + await authStateProvider.SetSession(jwtUser, cancellationToken); + await authStateProvider.ForgetPersonaTokens(cancellationToken); + var session = await authStateProvider.GetAuthData(cancellationToken); + if (session.PersonaId is null) + return new WebResult { Data = jwtUser }; + + using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken); + if (!exchanged.IsSuccessStatusCode) + await LogFailure(exchanged, nameof(ChangePassword), cancellationToken); + return new WebResult { Data = jwtUser }; + } + + /// + /// Asks PrivaPub to mail a recovery link to the login named, or to the login whose verified address is given. Data is + /// PrivaPub's answer, the same sentence whether or not there is such a login. Sent with no credentials. + /// + public async Task RequestRecovery(PasswordRecoveryForm form, CancellationToken cancellationToken = default) + { + using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverPassword, form, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await ReadSentence(response, cancellationToken) }; + } + + /// Whether a recovery link's code still works (Data, a bool): it lasts an hour and is used once. + public async Task CheckRecoveryCode(string recoveryCode, CancellationToken cancellationToken = default) + { + // [FromBody] string: the code goes as a JSON string + using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverValid, recoveryCode, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + /// Sets a new password through a recovery link's code; PrivaPub ends every session the login had. + public async Task Recover(NewPasswordForm form, CancellationToken cancellationToken = default) + { + using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverUpdatePassword, form, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult(); + } + + /// The login itself (its name, its email address and whether it is verified, one waiting for its code), or null. + public async Task Account(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + await LogFailure(response, nameof(Account), cancellationToken); + return null; + } + + return await response.DefaultReadFromJsonAsync(cancellationToken); + } + + /// + /// Asks for the login's address to become another, the password given: PrivaPub mails a code to it, which VerifyEmail + /// brings back. Data is PrivaPub's answer, the same sentence whether or not the address is free. + /// + public async Task ChangeEmail(EmailChangeForm form, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_Email, form, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await ReadPasswordFailure(response, cancellationToken); + + return new WebResult { Data = await ReadSentence(response, cancellationToken) }; + } + + /// Makes the address waiting for its code the login's, verified; Data is the login as it is now. + public async Task VerifyEmail(string code, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_EmailVerify, new EmailCodeForm { Code = code }, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + /// Removes the login's address, the password given; without one a forgotten password can't be recovered. + /// Data is the login as it is now. + public async Task RemoveEmail(string password, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_EmailRemove, new PasswordForm { Password = password }, + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await ReadPasswordFailure(response, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + /// + /// Ends every session of the login, this device's too, and PrivaPub revokes every persona's token with them. The client + /// forgets its session at once; the caller signs out, which then has nothing left to tell PrivaPub. + /// + public async Task LogoutEverywhere(CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_LogoutEverywhere, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + await authStateProvider.LogoutAsync(cancellationToken); + return new WebResult(); + } + + /// The theme and language the login keeps for every device, or null; SavedAt is null until a client saved them. + public async Task Settings(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_UserSettings, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + await LogFailure(response, nameof(Settings), cancellationToken); + return null; + } + + return await response.DefaultReadFromJsonAsync(cancellationToken); + } + + /// Saves the theme and language as the login's, for every device signed in to it. A failure is logged here. + public async Task SaveSettings(ViewUserSettings settings, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_UpdateUserSettings, settings, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not (int)HttpStatusCode.Gone) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(SaveSettings)}", cancellationToken); + return result; + } + return new WebResult(); } @@ -191,6 +334,32 @@ namespace decePubClient.Services await authStateProvider.LogoutAsync(cancellationToken); } + /// + /// The failure of what only the password may do: PrivaPub refuses a wrong one with 403, which ReadWebResult would + /// take for a missing permission. + /// + async Task ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader)) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden); + } + + /// A sentence PrivaPub answered with: a JSON string, or plain text (ASP.NET writes a string so unless JSON is asked for). + static async Task ReadSentence(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var text = await response.Content.ReadAsStringAsync(cancellationToken); + return text.StartsWith('"') ? JsonSerializer.Deserialize(text, SUtility.DefaultSerializer) : text; + } + catch (Exception ex) when (ex is OperationCanceledException or JsonException) + { + return null; + } + } + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) { var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); diff --git a/Services/TokenAuthStateProvider.cs b/Services/TokenAuthStateProvider.cs index a668ce8..9957cfb 100644 --- a/Services/TokenAuthStateProvider.cs +++ b/Services/TokenAuthStateProvider.cs @@ -89,6 +89,16 @@ namespace decePubClient.Services await Save(session, cancellationToken); } + /// Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used. + public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default) + { + var session = await GetAuthData(cancellationToken); + if (session.PersonaTokens.Count == 0) + return; + session.PersonaTokens.Clear(); + await Save(session, cancellationToken); + } + public async Task LogoutAsync(CancellationToken cancellationToken = default) { logger.LogInformation($"set null({nameof(LogoutAsync)})");