R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
Build / Build (push) Successful in 1m3s

Completion program R3, with PrivaPub's TOTP routes of the same release.

- Settings, the login: TotpEditor turns two-factor sign-in on (the password, then the QR code, the secret and the
  otpauth:// link, then the app's first code; the ten recovery codes shown once with Copy), shows how many recovery
  codes are left (warned at three), and turns it off with the password and a code, asked first.
- Turning it on, like a password change, ends every other session: AuthService.ContinueSession takes the new JWT and
  exchanges the persona's token again.
- Sign-in: PrivaPub's 428 keeps the name and password and asks for the six digits (DOtp), or a recovery code instead;
  a wrong code says so; Back returns to the password.
- window.copyText (main.js) through AppStatusService.CopyText.
- 29 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:
thepraandClaude Opus 5.5 committed 2026-10-17 03:33:46 +02:00
1 parent 7d4b35f0d7
commit b731d8c8c0
11 files changed
+692 -25

No files matched your search

+88 -15
View File
@@ -5,7 +5,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). A forgotten password is recovered through
the login's verified address (PasswordRecovery). *@
the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a
428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode). *@
<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">
@@ -26,20 +27,48 @@
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
@if (needsCode)
{
@* The name and the password stay in the form, out of sight, and go again with the code. *@
<p class="text-sm" data-login-code-asked>@Localizer["Enter the code from your authenticator app, or a recovery code."]</p>
@if (useRecoveryCode)
{
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Recovery code"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.OneTimeCode" required="@true" autocomplete="one-time-code" inputmode="text"
autocapitalize="none" spellcheck="false" data-login-recovery>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
}
else
{
<div class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
<DOtp @bind-Value="form.OneTimeCode" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-login-code />
</div>
}
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-center" OnClick="ToggleRecoveryCode" data-login-use-recovery>
@(useRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
</DButton>
}
else
{
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
}
<ErrorViewer AdditionalError="@error" />
@@ -50,7 +79,16 @@
}
<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>
@if (needsCode)
{
<DButton Variant="DButtonVariant.Ghost" Size="DSize.Sm" class="self-center" Disabled="isWorking" OnClick="Back" data-login-back>
<i class="ion-md-arrow-back"></i><span>@Localizer["Back"]</span>
</DButton>
}
else
{
<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>
@@ -101,6 +139,10 @@
string error;
bool isWorking;
bool setupPending;
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
bool needsCode;
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
bool useRecoveryCode;
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
@@ -124,16 +166,30 @@
{
error = default;
isWorking = true;
form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default;
var result = await AuthService.Login(form);
if (AuthService.NeedsOneTimeCode(result))
{
needsCode = true;
form.OneTimeCode = default;
isWorking = false;
return;
}
if (result.IsInvalid)
{
// a wrong code is PrivaPub's 400, like a wrong password, with its own message; the code is typed again
error = result.ErrorMessage;
form.OneTimeCode = default;
isWorking = false;
return;
}
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue();
}
@@ -153,6 +209,23 @@
Continue();
}
void ToggleRecoveryCode()
{
useRecoveryCode = !useRecoveryCode;
form.OneTimeCode = default;
error = default;
}
/// <summary>Back to the name and the password, for another login: the password is typed again.</summary>
void Back()
{
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
form.Password = default;
error = default;
}
async Task SignOut() => await CascadingState.SignOut();
/// <summary>Back where the reader was sent from, never off the app.</summary>