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
238 lines
8.9 KiB
Plaintext
238 lines
8.9 KiB
Plaintext
@page "/login"
|
|
@inherits LocalizableComponentBase
|
|
<Title>@Localizer["Login"]</Title>
|
|
|
|
@* 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). 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">
|
|
|
|
@if (!CascadingState.IsSignedIn)
|
|
{
|
|
<div class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16">
|
|
@if (setupPending)
|
|
{
|
|
<a href="setup" class="flex items-center gap-3 w-full p-4 rounded-xl neo-raised neo-sm ring-2 ring-primary" data-setup-offer>
|
|
<i class="ion-md-construct text-2xl text-primary" aria-hidden="true"></i>
|
|
<span class="flex flex-col">
|
|
<b>@Localizer["This server has no administrator yet"]</b>
|
|
<span class="text-sm">@Localizer["Set it up: the first login made becomes its administrator."]</span>
|
|
</span>
|
|
</a>
|
|
}
|
|
<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>
|
|
@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>
|
|
}
|
|
|
|
<ErrorViewer AdditionalError="@error" />
|
|
|
|
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking">
|
|
@if (isWorking)
|
|
{
|
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
|
}
|
|
<span>@Localizer["Login"]</span>
|
|
</DButton>
|
|
@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>
|
|
</p>
|
|
</div>
|
|
}
|
|
else if (CascadingState.Personas.Count == 0)
|
|
{
|
|
<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-no-persona>
|
|
<h1 class="text-xl font-bold">@Localizer["No persona yet"]</h1>
|
|
<p class="text-sm">@Localizer["This login has no persona to act as yet: make one in Settings."]</p>
|
|
<DButton Href="settings" Color="DColor.Primary">@Localizer["Settings"]</DButton>
|
|
<DButton OnClick="SignOut">@Localizer["Logout"]</DButton>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="flex flex-col gap-4 w-full max-w-md mt-6 md:mt-16">
|
|
<h1 class="text-xl font-bold text-center">@Localizer["Who are you today?"]</h1>
|
|
<p class="text-sm opacity-70 text-center">@Localizer["Each persona has its own token; PrivaPub never tells anyone they share a login."]</p>
|
|
@foreach (var persona in CascadingState.Personas)
|
|
{
|
|
<button type="button" class="flex items-center gap-3 p-3 rounded-xl text-left neo-raised neo-xs hover:neo-sm transition-shadow @(persona.Id == CascadingState.PersonaId ? "ring-2 ring-primary" : null)"
|
|
disabled="@(IsDefaultDisabled || isWorking)" @onclick="() => Choose(persona.Id)">
|
|
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full neo-raised neo-2xs" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
|
|
<span class="flex flex-col min-w-0">
|
|
<b class="truncate">@(persona.Name ?? persona.UserName)</b>
|
|
<span class="text-xs opacity-70 truncate">@(persona.Handle ?? "@" + persona.UserName)</span>
|
|
</span>
|
|
@if (persona.Id == CascadingState.PersonaId)
|
|
{
|
|
<i class="ion-md-checkmark-circle text-primary text-xl ml-auto"></i>
|
|
}
|
|
</button>
|
|
}
|
|
<ErrorViewer AdditionalError="@error" />
|
|
</div>
|
|
}
|
|
|
|
</div>
|
|
|
|
</section>
|
|
|
|
@code {
|
|
[Inject] AuthService AuthService { get; set; }
|
|
|
|
readonly LoginForm form = new();
|
|
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; }
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
if (CascadingState.IsOffline)
|
|
{
|
|
WhenOnline(LoadData);
|
|
StartOnlinePolling();
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
await LoadData();
|
|
IsLoading = false;
|
|
}
|
|
|
|
async Task LoadData() => setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending();
|
|
|
|
async Task SignIn()
|
|
{
|
|
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();
|
|
}
|
|
|
|
async Task Choose(string personaId)
|
|
{
|
|
error = default;
|
|
isWorking = true;
|
|
var result = await CascadingState.SwitchPersona(personaId);
|
|
isWorking = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
error = result.ErrorMessage;
|
|
return;
|
|
}
|
|
|
|
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>
|
|
void Continue()
|
|
{
|
|
var target = ReturnUrl is { Length: > 0 } url && !url.Contains("://") && !url.StartsWith("//") && !url.StartsWith("login") ? url : "";
|
|
Navigation.NavigateTo(target);
|
|
}
|
|
}
|