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
307 lines
12 KiB
Plaintext
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."]);
|
|
}
|
|
}
|