Files
decePubClient/Pages/Login.razor
T
thepraandClaude Opus 5.5 7d4b35f0d7 R2 accounts in decePub: forgotten password, email by code, sessions, the theme kept with the login
Completion program R2, with PrivaPub's routes of the same release.

- Pages/PasswordRecovery.razor (/password-recovery): asks for a login or an address and shows the server's one
  sentence; with ?rc= it takes the code out of the address bar at once, checks it and sets a new password. Login links
  there ("Forgot your password?").
- Settings, the login: its email address with a verified badge, changed with the password and the code emailed to it
  (DOtp), removed with the password; "Sign out everywhere" (every device and persona, asked first).
- A password change takes the new JWT PrivaPub answers and exchanges the persona's token again, since every persona
  token ended with it.
- The theme kept with the login (PageSettings.SyncWithLogin, Settings > General): taken at sign-in and start-up once a
  client saved it, saved 1.5 s after a change here, never posted back when nothing changed, never the language.
- Login, Setup and Join start the session through CascadingState.SignedIn, which takes the login's theme.
- 38 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:21:59 +02:00

165 lines
6.1 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). *@
<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>
<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>
<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;
[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;
var result = await AuthService.Login(form);
if (result.IsInvalid)
{
error = result.ErrorMessage;
isWorking = false;
return;
}
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
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();
}
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);
}
}