Setup and invitations: the first login made from the client, everyone else by a group's invitation
Build / Build (push) Successful in 52s
Deploy / decepub.thepra.dev (push) Successful in 3m3s

PrivaPub's sign-up is closed. While it has no administrator, Login offers /setup, where the first login is made with its
first persona and becomes the administrator (PrivaPub's /clientapi/user/setup; owner decision 2026-10-16: first come,
first served, and setup then closes for good). Everyone else joins at /join?code=: a group's invitation makes the login,
and its first persona joins the group (/clientapi/user/invitation/signup). Login links there, and a group's page shows
the link next to its code. A login without a persona is sent from Login to Settings instead of a dead end. Login, setup
and join share AuthService's StartSession, and all three go without credentials.

e2e (Login/SignUpTests, against the pasture): setup offered on Login, made, the feed reached with Administration, then a
signed-out reader is offered sign-in and invitations only; an invitation link makes a login whose persona joins the
circle. The phone sweep covers /setup and /join: login, setup and join fit every portrait phone, and AccountTests signs
in through the changed Login. The town's signed-in tests were not rerun: today's pasture came up without Mastodon, and
its town seed left stale sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-16 23:21:48 +02:00
1 parent 4da6a398d7
commit 5796d62406
12 files changed
+630 -35

No files matched your search

+61 -29
View File
@@ -3,47 +3,64 @@
<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). *@
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). *@
<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>
<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>
<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" />
<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>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking">
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Login"]</span>
</DButton>
</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">
<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. Create one on PrivaPub first."]</p>
<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>
}
@@ -81,10 +98,25 @@
readonly LoginForm form = new();
string error;
bool isWorking;
bool setupPending;
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
protected override void OnInitialized() => IsLoading = false;
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()
{