Files
decePubClient/Components/Settings/TotpEditor.razor
T
thepraandClaude Opus 5.5 b731d8c8c0
Build / Build (push) Successful in 1m3s
R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
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
2026-10-17 03:33:46 +02:00

307 lines
12 KiB
Plaintext

@inherits LocalizableComponentBase
@* Two-factor sign-in (TOTP, /clientapi/user/totp), a block of the login's settings (AccountEditor). Off: turned on with
the password (setup), then the secret shown as a QR code, as text and as an otpauth:// link an authenticator app opens,
and the app's first code (enable); then the ten recovery codes, shown this once. Turning it on ends every other session
and every persona token, this device's too, and AuthService.TotpEnable goes on with the JWT PrivaPub answers. On: the
recovery codes left, and turned off with the password and a code from the app or a recovery code, asked first. *@
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-totp>
<p class="flex flex-wrap items-center gap-2">
<b class="text-sm">@Localizer["Two-factor sign-in"]</b>
@if (account is { TwoFactorEnabled: true })
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-totp-on>@Localizer["active"]</DBadge>
}
else if (account is not null)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-totp-off>@Localizer["not active"]</DBadge>
}
</p>
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (recoveryCodes.Count > 0)
{
<div class="flex flex-col gap-3" data-totp-codes>
<DAlert Color="DAlertColor.Success" Variant="DAlertVariant.Soft" role="status">
<Icon><i class="ion-md-checkmark-circle text-xl" aria-hidden="true"></i></Icon>
<ChildContent>@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]</ChildContent>
</DAlert>
<p class="text-sm">
<b>@Localizer["Save these recovery codes now: they won't be shown again."]</b>
@Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]
</p>
<ol class="grid grid-cols-2 gap-x-4 gap-y-1 p-3 rounded-lg neo-inset neo-2xs font-mono text-sm select-all" data-totp-recovery-codes>
@foreach (var recoveryCode in recoveryCodes)
{
<li>@recoveryCode</li>
}
</ol>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CopyCodes" data-totp-copy>
<i class="ion-md-copy"></i><span>@Localizer["Copy"]</span>
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="CodesSaved" data-totp-saved>
<i class="ion-md-checkmark"></i><span>@Localizer["I saved them"]</span>
</DButton>
</div>
</div>
}
else if (account is { TwoFactorEnabled: true })
{
<p class="text-sm">@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]</p>
<p class="text-sm" data-totp-codes-left>@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]</p>
@if (account.RecoveryCodesLeft <= FewRecoveryCodes)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" role="status" data-totp-codes-few>
<Icon><i class="ion-md-warning text-xl" aria-hidden="true"></i></Icon>
<ChildContent>@Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."]</ChildContent>
</DAlert>
}
@if (isDisabling)
{
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-totp-disable-password />
@if (disableWithRecoveryCode)
{
<DInput TValue="string" @bind-Value="disable.Code" Immediate="true" required="@true" autocomplete="one-time-code" inputmode="text"
autocapitalize="none" spellcheck="false" placeholder="@Localizer["Recovery code"]" data-totp-disable-recovery />
}
else
{
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
<DOtp @bind-Value="disable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-disable-code />
}
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-start" OnClick="ToggleDisableRecoveryCode" data-totp-disable-use-recovery>
@(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
</DButton>
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-disable-confirm>
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<DButton Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenDisable" data-totp-disable-open>
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
</DButton>
}
}
else if (account is not null)
{
<p class="text-sm">@Localizer["Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough."]</p>
@if (setup is not null)
{
<div class="flex flex-col gap-3" data-totp-scan>
<p class="text-sm">@Localizer["Scan this QR code with your authenticator app, or type the key into it."]</p>
<img src="@setup.QrDataUri" alt="@Localizer["QR code of the key, for your authenticator app"]"
class="self-center w-48 h-48 p-2 rounded-lg bg-white" data-totp-qr />
<div class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Key"]</span>
<code class="p-2 rounded-lg neo-inset neo-2xs font-mono text-sm break-all select-all" data-totp-secret>@setup.Secret</code>
</div>
@* an <a href> to otpauth://, which a phone with an authenticator app opens in it *@
<DButton Href="@setup.OtpAuthUri" Size="DSize.Sm" class="self-center" data-totp-uri>
<i class="ion-md-open"></i><span>@Localizer["Open in the authenticator app"]</span>
</DButton>
<EditForm Model="enable" OnValidSubmit="Enable" class="flex flex-col gap-2" data-totp-enable-form>
<DataAnnotationsValidator />
<span class="text-sm">@Localizer["Then enter the 6-digit code the app shows."]</span>
<DOtp @bind-Value="enable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-code />
<ErrorViewer />
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-enable>
<i class="ion-md-lock"></i><span>@Localizer["Turn on"]</span>
</DButton>
</div>
</EditForm>
</div>
}
else if (isAskingPassword)
{
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-totp-setup-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-setup-confirm>
<i class="ion-md-arrow-forward"></i><span>@Localizer["Continue"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenSetup"
data-totp-setup-open>
<i class="ion-md-lock"></i><span>@Localizer["Turn on two-factor sign-in"]</span>
</DButton>
}
}
</div>
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>From this many recovery codes left down, the block warns.</summary>
const int FewRecoveryCodes = 3;
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty).</summary>
ViewAccount account;
PasswordForm setupPassword = new();
/// <summary>What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise.</summary>
ViewTotpSetup setup;
TotpCodeForm enable = new();
TotpDisableForm disable = new();
/// <summary>The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise.</summary>
List<string> recoveryCodes = [];
bool isAskingPassword;
bool isDisabling;
/// <summary>Turned off with a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
bool disableWithRecoveryCode;
bool isSaving;
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 OpenSetup()
{
setupPassword = new();
isAskingPassword = true;
}
void OpenDisable()
{
disable = new();
disableWithRecoveryCode = false;
isDisabling = true;
}
/// <summary>Back to the block's state; a setup PrivaPub keeps expires by itself after an hour.</summary>
void CloseForms()
{
isAskingPassword = false;
isDisabling = false;
setup = default;
setupPassword = new();
enable = new();
disable = new();
}
void ToggleDisableRecoveryCode()
{
disableWithRecoveryCode = !disableWithRecoveryCode;
disable.Code = default;
}
async Task Setup()
{
isSaving = true;
var result = await AuthService.TotpSetup(setupPassword.Password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
// a 409: two-factor sign-in was turned on meanwhile, from another device
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
{
CloseForms();
account = await AuthService.Account() ?? account;
}
return;
}
setup = result.Data as ViewTotpSetup;
setupPassword = new();
enable = new();
isAskingPassword = false;
}
async Task Enable()
{
isSaving = true;
// every other session ends here, and this device goes on with a new JWT and a new token for the persona
var result = await AuthService.TotpEnable(enable.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
enable = new();
// a 409: the setup expired (an hour), so it begins again with the password
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
{
CloseForms();
account = await AuthService.Account() ?? account;
isAskingPassword = account is { TwoFactorEnabled: false };
}
return;
}
recoveryCodes = result.Data as List<string> ?? [];
setup = default;
enable = new();
account.TwoFactorEnabled = true;
account.RecoveryCodesLeft = recoveryCodes.Count;
}
async Task CopyCodes()
{
// a refusal is reported by main.js; the codes can still be selected and copied by hand
if (!await CascadingState.Status.CopyText(string.Join('\n', recoveryCodes)))
return;
Toast.AddResponseSuccess(Localizer["Recovery codes copied."]);
}
void CodesSaved() => recoveryCodes = [];
async Task Disable()
{
if (!await Confirm.Ask(Localizer["Turn off two-factor sign-in?"],
Localizer["Signing in takes only the password again, and the recovery codes stop working."], Localizer["Turn off"], true))
return;
isSaving = true;
var result = await AuthService.TotpDisable(disable.Password, disable.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
disable.Code = default;
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
CloseForms();
Toast.AddResponseSuccess(Localizer["Two-factor sign-in is off."]);
}
}