Setup and invitations: the first login made from the client, everyone else by a group's invitation
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:
1 parent
4da6a398d7
commit
5796d62406
12 files changed
+630
-35
No files matched your search
@@ -110,6 +110,9 @@
|
||||
<span>@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)</span>
|
||||
<input class="input input-sm w-full font-mono text-xs" value="@invitation" readonly autocomplete="off" onfocus="this.select()"
|
||||
aria-label="@Localizer["Invitation code"]" data-group-invitation />
|
||||
<span>@Localizer["Invitation link, for someone without a login here"]</span>
|
||||
<input class="input input-sm w-full font-mono text-xs" value="@($"{Navigation.BaseUri}join?code={Uri.EscapeDataString(invitation)}")" readonly
|
||||
autocomplete="off" onfocus="this.select()" aria-label="@Localizer["Invitation link, for someone without a login here"]" data-group-invitation-link />
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
@page "/join"
|
||||
@inherits LocalizableComponentBase
|
||||
<Title>@Localizer["Join with an invitation"]</Title>
|
||||
|
||||
@* A new login through a group's invitation (PrivaPub's /clientapi/user/invitation/signup): sign-up is otherwise closed,
|
||||
so this is how everyone but the first administrator arrives. Its first persona joins the group. A group's page links
|
||||
here with the code (?code=). *@
|
||||
<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-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-join-signed-in>
|
||||
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
|
||||
<p class="text-sm">@Localizer["A persona of yours joins a group by its invitation in Groups."]</p>
|
||||
<DButton Href="groups" Color="DColor.Primary">@Localizer["Groups"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="form" OnValidSubmit="SignUp" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-join>
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Join with an invitation"]</h1>
|
||||
<p class="text-sm opacity-70">@Localizer["A group's invitation makes your login here, and your first persona joins the group."]</p>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Invitation code"]</span>
|
||||
<DInput TValue="string" class="w-full font-mono text-xs" @bind-Value="form.InvitationCode" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
maxlength="@PrivaPub.ClientModels.Constants.GroupInvitationLength" data-join-code />
|
||||
</label>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.InvitationPassword" autocomplete="off"
|
||||
placeholder="@Localizer["Invitation password, if it has one"]" data-join-code-password />
|
||||
|
||||
<b class="text-sm">@Localizer["Your login"]</b>
|
||||
<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" data-join-username>
|
||||
<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="new-password" data-join-password>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Repeat the password"]</span>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="repeatedPassword" autocomplete="new-password" data-join-repeat>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
|
||||
<b class="text-sm">@Localizer["Your first persona"]</b>
|
||||
<p class="text-xs opacity-70">@Localizer["Others see your personas, never your login."]</p>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarUserName" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-join-persona-username>
|
||||
<Prefix>@@</Prefix>
|
||||
</DInput>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarName" autocomplete="off" maxlength="64"
|
||||
placeholder="@Localizer["Display name"]" data-join-persona-name />
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-join-submit>
|
||||
@if (isWorking)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Join"]</span>
|
||||
</DButton>
|
||||
<a class="link text-sm self-center touch-target" href="login">@Localizer["I have a login: sign in"]</a>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
|
||||
readonly InvitationLoginForm form = new();
|
||||
string repeatedPassword;
|
||||
string error;
|
||||
bool isWorking;
|
||||
|
||||
[SupplyParameterFromQuery] public string Code { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
form.InvitationCode = Code?.Trim();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task SignUp()
|
||||
{
|
||||
error = default;
|
||||
if (form.Password != repeatedPassword)
|
||||
{
|
||||
error = Localizer["The two passwords don't match."];
|
||||
return;
|
||||
}
|
||||
|
||||
isWorking = true;
|
||||
form.InvitationCode = form.InvitationCode?.Trim();
|
||||
form.InvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? null : form.InvitationPassword;
|
||||
form.AvatarUserName = form.AvatarUserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||
var result = await AuthService.Join(form);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
isWorking = false;
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.LoadSession();
|
||||
isWorking = false;
|
||||
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
|
||||
Navigation.NavigateTo("");
|
||||
}
|
||||
}
|
||||
+61
-29
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
@page "/setup"
|
||||
@inherits LocalizableComponentBase
|
||||
<Title>@Localizer["Set up PrivaPub"]</Title>
|
||||
|
||||
@* The server's first login, made while it has no administrator (PrivaPub's /clientapi/user/setup; owner decision 2026-10-16,
|
||||
first come, first served): it becomes the administrator, with its first persona, and setup closes for good. Everyone
|
||||
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 (IsLoading)
|
||||
{
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<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["You are signed in"]</h1>
|
||||
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else if (!isPending)
|
||||
{
|
||||
<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-setup-done>
|
||||
<h1 class="text-xl font-bold">@Localizer["This server is set up"]</h1>
|
||||
<p class="text-sm">@Localizer["It has an administrator already: sign in, or join with a group's invitation."]</p>
|
||||
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2">
|
||||
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
|
||||
<DButton Href="join">@Localizer["Join with an invitation"]</DButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="form" OnValidSubmit="SetUp" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-setup>
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Set up PrivaPub"]</h1>
|
||||
<p class="text-sm opacity-70">@Localizer["This server has no administrator yet. The first login made here becomes it, with its first persona; after that, joining takes a group's invitation."]</p>
|
||||
|
||||
<b class="text-sm">@Localizer["Your login"]</b>
|
||||
<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" data-setup-username>
|
||||
<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="new-password" data-setup-password>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Repeat the password"]</span>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="repeatedPassword" autocomplete="new-password" data-setup-repeat>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
|
||||
<b class="text-sm">@Localizer["Your first persona"]</b>
|
||||
<p class="text-xs opacity-70">@Localizer["Others see your personas, never your login."]</p>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarUserName" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-setup-persona-username>
|
||||
<Prefix>@@</Prefix>
|
||||
</DInput>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarName" autocomplete="off" maxlength="64"
|
||||
placeholder="@Localizer["Display name"]" data-setup-persona-name />
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-setup-submit>
|
||||
@if (isWorking)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Set up PrivaPub"]</span>
|
||||
</DButton>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
|
||||
readonly SetupForm form = new();
|
||||
string repeatedPassword;
|
||||
string error;
|
||||
bool isPending;
|
||||
bool isWorking;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => isPending = await AuthService.SetupPending();
|
||||
|
||||
async Task SetUp()
|
||||
{
|
||||
error = default;
|
||||
if (form.Password != repeatedPassword)
|
||||
{
|
||||
error = Localizer["The two passwords don't match."];
|
||||
return;
|
||||
}
|
||||
|
||||
isWorking = true;
|
||||
form.AvatarUserName = form.AvatarUserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||
var result = await AuthService.SetUp(form);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
isWorking = false;
|
||||
// set up meanwhile: the page says so
|
||||
isPending = result.StatusCode != (int)System.Net.HttpStatusCode.Forbidden;
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.LoadSession();
|
||||
isWorking = false;
|
||||
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
|
||||
Navigation.NavigateTo("");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user