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
+1
-1
@@ -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("");
|
||||
|
||||
+4
-2
@@ -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). *@
|
||||
<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">
|
||||
@@ -49,6 +50,7 @@
|
||||
}
|
||||
<span>@Localizer["Login"]</span>
|
||||
</DButton>
|
||||
<a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a>
|
||||
</EditForm>
|
||||
<p class="text-sm text-center">
|
||||
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
@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");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@
|
||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
||||
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
||||
@* The theme is the login's: taken at sign-in, saved to it as it changes here (CascadingState.SaveSettingsSoon). *@
|
||||
<DToggle LabelText="@Localizer["Keep the theme the same on every device signed in to this login"]" Value="CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
|
||||
ValueChanged="v => Update(s => s.SyncWithLogin = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
|
||||
data-sync-with-login />
|
||||
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||
data-remind-alt-text />
|
||||
|
||||
+1
-1
@@ -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("");
|
||||
|
||||
Reference in new issue
Block a user