@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. *@

@Localizer["Two-factor sign-in"] @if (account is { TwoFactorEnabled: true }) { @Localizer["active"] } else if (account is not null) { @Localizer["not active"] }

@if (IsLoading) { } else if (recoveryCodes.Count > 0) {
@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]

@Localizer["Save these recovery codes now: they won't be shown again."] @Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]

    @foreach (var recoveryCode in recoveryCodes) {
  1. @recoveryCode
  2. }
@Localizer["Copy"] @Localizer["I saved them"]
} else if (account is { TwoFactorEnabled: true }) {

@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]

@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]

@if (account.RecoveryCodesLeft <= FewRecoveryCodes) { @Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."] } @if (isDisabling) { @if (disableWithRecoveryCode) { } else { @Localizer["Code from the authenticator app"] } @(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
@Localizer["Cancel"] @Localizer["Turn off"]
} else { @Localizer["Turn off"] } } else if (account is not null) {

@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."]

@if (setup is not null) { } else if (isAskingPassword) {
@Localizer["Cancel"] @Localizer["Continue"]
} else { @Localizer["Turn on two-factor sign-in"] } }
@code { [Inject] AuthService AuthService { get; set; } [Inject] ConfirmService Confirm { get; set; } /// From this many recovery codes left down, the block warns. const int FewRecoveryCodes = 3; /// The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty). ViewAccount account; PasswordForm setupPassword = new(); /// What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise. ViewTotpSetup setup; TotpCodeForm enable = new(); TotpDisableForm disable = new(); /// The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise. List recoveryCodes = []; bool isAskingPassword; bool isDisabling; /// Turned off with a recovery code (two groups of five letters and digits), not the app's six digits. 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; } /// Back to the block's state; a setup PrivaPub keeps expires by itself after an hour. 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 ?? []; 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."]); } }