R2 accounts in decePub: forgotten password, email by code, sessions, the theme kept with the login
Completion program R2, with PrivaPub's routes of the same release.
- Pages/PasswordRecovery.razor (/password-recovery): asks for a login or an address and shows the server's one
sentence; with ?rc= it takes the code out of the address bar at once, checks it and sets a new password. Login links
there ("Forgot your password?").
- Settings, the login: its email address with a verified badge, changed with the password and the code emailed to it
(DOtp), removed with the password; "Sign out everywhere" (every device and persona, asked first).
- A password change takes the new JWT PrivaPub answers and exchanges the persona's token again, since every persona
token ended with it.
- The theme kept with the login (PageSettings.SyncWithLogin, Settings > General): taken at sign-in and start-up once a
client saved it, saved 1.5 s after a change here, never posted back when nothing changed, never the language.
- Login, Setup and Join start the session through CascadingState.SignedIn, which takes the login's theme.
- 38 keys in both resx files; CLAUDE.md follows.
Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
fd2f2e7c65
commit
7d4b35f0d7
15 files changed
+1056
-17
No files matched your search
+170
-1
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Changes the root's password, the current one given.</summary>
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </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);
|
||||
|
||||
// a server from before sessions answered with nothing, and ended none
|
||||
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<WebResult> 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) };
|
||||
}
|
||||
|
||||
/// <summary>Whether a recovery link's code still works (Data, a bool): it lasts an hour and is used once.</summary>
|
||||
public async Task<WebResult> 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<bool>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Sets a new password through a recovery link's code; PrivaPub ends every session the login had.</summary>
|
||||
public async Task<WebResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>The login itself (its name, its email address and whether it is verified, one waiting for its code), or null.</summary>
|
||||
public async Task<ViewAccount> 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<ViewAccount>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<WebResult> 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) };
|
||||
}
|
||||
|
||||
/// <summary>Makes the address waiting for its code the login's, verified; Data is the login as it is now.</summary>
|
||||
public async Task<WebResult> 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<ViewAccount>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<WebResult> 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<ViewAccount>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<WebResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>The theme and language the login keeps for every device, or null; SavedAt is null until a client saved them.</summary>
|
||||
public async Task<ViewUserSettings> 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<ViewUserSettings>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Saves the theme and language as the login's, for every device signed in to it. A failure is logged here.</summary>
|
||||
public async Task<WebResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The failure of what only the password may do: PrivaPub refuses a wrong one with 403, which ReadWebResult would
|
||||
/// take for a missing permission.
|
||||
/// </summary>
|
||||
async Task<WebResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>A sentence PrivaPub answered with: a JSON string, or plain text (ASP.NET writes a string so unless JSON is asked for).</summary>
|
||||
static async Task<string> ReadSentence(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return text.StartsWith('"') ? JsonSerializer.Deserialize<string>(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);
|
||||
|
||||
@@ -89,6 +89,16 @@ namespace decePubClient.Services
|
||||
await Save(session, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary>
|
||||
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)})");
|
||||
|
||||
Reference in new issue
Block a user