One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi, picks a persona, and each persona gets its own Mastodon token in exchange for the JWT (PersonaExchange). HttpService picks credentials and JSON by route (JWT and camelCase for /clientapi, the persona token and snake_case for /api, nothing for /oauth), retries a refused persona token once, and records every request for the nerd stats. The feed page is three columns: navigation with a persona switcher, the feed with a tab per kind (home, local, federated, nearby, communities and circles, direct, notifications, favourites, bookmarks, hashtags), and a 3D globe that looks at the spherical mean of the posts in view and draws a pulse and an arc to a hovered one. Posts sit at their own place, their event's venue, the reader (located posts) or their author's server as PrivaPub publishes it. Cards favourite, boost, bookmark, reply, mute, block and delete; the composer posts with a content warning, visibility, media and alt text, or as a located post. Older pages load as the end nears; newer ones are polled while visible. Nerd stats in tooltips: provenance, author and server, place, media, counts, paging, the globe's camera and frame rate. The mock (Faker, MessagesService, the Message models and store) and the OIDC template leftovers are gone. Strings in English and Italian. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
131 lines
4.6 KiB
Plaintext
131 lines
4.6 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). *@
|
|
<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)
|
|
{
|
|
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 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>
|
|
</EditForm>
|
|
}
|
|
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">
|
|
<h1 class="text-xl font-bold">@Localizer["No persona yet"]</h1>
|
|
<p class="text-sm">@Localizer["This login has no persona to act as. Create one on PrivaPub first."]</p>
|
|
<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;
|
|
|
|
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
|
|
|
|
protected override void OnInitialized() => IsLoading = false;
|
|
|
|
async Task SignIn()
|
|
{
|
|
error = default;
|
|
isWorking = true;
|
|
var result = await AuthService.Login(form);
|
|
if (result.IsInvalid)
|
|
{
|
|
error = result.ErrorMessage;
|
|
isWorking = false;
|
|
return;
|
|
}
|
|
|
|
await CascadingState.LoadSession();
|
|
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);
|
|
}
|
|
}
|