Files
decePubClient/Pages/PasswordRecovery.razor
T
thepraandClaude Opus 5.5 7d4b35f0d7 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
2026-10-17 03:21:59 +02:00

217 lines
8.0 KiB
Plaintext

@page "/password-recovery"
@inherits LocalizableComponentBase
<Title>@Localizer["Forgot your password?"]</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@if (CascadingState.IsSignedIn)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-signed-in>
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
</div>
}
else if (recoveryCode is null && sentence is not null)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-sent>
<h1 class="text-xl font-bold">@Localizer["Check your inbox"]</h1>
<p class="text-sm">@sentence</p>
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
</div>
}
else if (recoveryCode is null)
{
<EditForm Model="request" OnValidSubmit="Request" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-request>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Forgot your password?"]</h1>
<p class="text-sm opacity-70">@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."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName"
Immediate="true" disabled="@request.IsUsernameDisabled" autocomplete="username" autocapitalize="none" spellcheck="false" data-recovery-username>
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<span class="text-sm text-center opacity-70">@Localizer["or"]</span>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Email address"]</span>
<DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email"
Immediate="true" disabled="@request.IsEmailDisabled" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email>
<Prefix><i class="ion-md-mail"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Send the recovery link"]</span>
</DButton>
<a class="link text-sm self-center touch-target" href="login">@Localizer["I remember it: sign in"]</a>
</EditForm>
}
else if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
}
else if (isCodeValid is false)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-invalid>
<h1 class="text-xl font-bold">@Localizer["This recovery link doesn't work"]</h1>
<p class="text-sm">@Localizer["A link works for one hour, and only once. Ask for a new one."]</p>
<DButton Color="DColor.Primary" OnClick="AskAgain" data-recovery-again>@Localizer["Ask for a new link"]</DButton>
</div>
}
else
{
@* also while the code couldn't be checked (offline, or the check failed): the answer to the new password says *@
<EditForm Model="newPassword" OnValidSubmit="Recover" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-new>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Choose a new password"]</h1>
<p class="text-sm opacity-70">@Localizer["Every device signed in to this login is signed out."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["New password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.NewPassword" autocomplete="new-password" data-recovery-password>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Repeat the new password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.RepeatedPassword" autocomplete="new-password" data-recovery-repeat>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-new-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Change the password"]</span>
</DButton>
</EditForm>
}
</div>
</section>
@code {
[Inject] AuthService AuthService { get; set; }
readonly PasswordRecoveryForm request = new();
NewPasswordForm newPassword = new();
/// <summary>The code of the link the page was opened from, kept here once it left the address bar.</summary>
string recoveryCode;
/// <summary>Whether the code works: null until PrivaPub said.</summary>
bool? isCodeValid;
/// <summary>PrivaPub's answer to a request, the same for everyone.</summary>
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");
}
}