diff --git a/CLAUDE.md b/CLAUDE.md
index e75ca46..40f566c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -296,6 +296,14 @@ supplies:
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
+- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16):
+ - **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first
+ login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
+ closes for good. Login offers it while it waits.
+ - **Invitations** (`Pages/Join.razor` at `/join?code=`): a group's invitation makes a login whose first persona joins
+ the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code.
+ - Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a
+ persona is sent from Login to Settings to make one.
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs
index bf85d02..a5d52ad 100644
--- a/ClientJsonContext.cs
+++ b/ClientJsonContext.cs
@@ -32,6 +32,9 @@ namespace decePubClient;
[JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))]
+[JsonSerializable(typeof(SetupForm))]
+[JsonSerializable(typeof(InvitationLoginForm))]
+[JsonSerializable(typeof(ViewServerSetup))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(JwtUser))]
diff --git a/Models/APIs.cs b/Models/APIs.cs
index 46f36eb..a628946 100644
--- a/Models/APIs.cs
+++ b/Models/APIs.cs
@@ -14,6 +14,9 @@ public static class APIs
public static class ClientApi
{
public const string POST_Login = "clientapi/user/login";
+ public const string GET_Setup = "clientapi/user/setup";
+ public const string POST_Setup = "clientapi/user/setup";
+ public const string POST_InvitationSignUp = "clientapi/user/invitation/signup";
public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string POST_UpdatePassword = "clientapi/user/update/password";
diff --git a/Pages/Groups.razor b/Pages/Groups.razor
index 18acac6..936986c 100644
--- a/Pages/Groups.razor
+++ b/Pages/Groups.razor
@@ -110,6 +110,9 @@
@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)
+ @Localizer["Invitation link, for someone without a login here"]
+
}
diff --git a/Pages/Join.razor b/Pages/Join.razor
new file mode 100644
index 0000000..d8bcfe7
--- /dev/null
+++ b/Pages/Join.razor
@@ -0,0 +1,123 @@
+@page "/join"
+@inherits LocalizableComponentBase
+
@Localizer["Join with an invitation"]
+
+@* 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=). *@
+
+
+
+
+ @if (CascadingState.IsSignedIn)
+ {
+
+
@Localizer["You are signed in"]
+
@Localizer["A persona of yours joins a group by its invitation in Groups."]
+ @Localizer["Groups"]
+
+ }
+ else
+ {
+
+
+
@Localizer["Join with an invitation"]
+
@Localizer["A group's invitation makes your login here, and your first persona joins the group."]
+
+
+
+@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("");
+ }
+}
diff --git a/Pages/Login.razor b/Pages/Login.razor
index 3da44dd..77fc197 100644
--- a/Pages/Login.razor
+++ b/Pages/Login.razor
@@ -3,47 +3,64 @@
@Localizer["Login"]
@* 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). *@
@if (!CascadingState.IsSignedIn)
{
-
-
-
@Localizer["Sign in to PrivaPub"]
-
@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]
@Localizer["It has an administrator already: sign in, or join with a group's invitation."]
+
+ @Localizer["Login"]
+ @Localizer["Join with an invitation"]
+
+
+ }
+ else
+ {
+
+
+
@Localizer["Set up PrivaPub"]
+
@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."]
+
+
+
+@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("");
+ }
+}
diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx
index b5af749..1ae70f8 100644
--- a/Resources/AllStrings.it.resx
+++ b/Resources/AllStrings.it.resx
@@ -425,9 +425,6 @@
Questo dispositivo non ha dato la sua posizione: {0}
-
- Questo accesso non ha una persona con cui agire. Creane prima una su PrivaPub.
-
Questa persona non è ancora in nessuna comunità o cerchia.
@@ -2246,4 +2243,67 @@
server bloccati
+
+ Configura PrivaPub
+
+
+ Hai già effettuato l'accesso
+
+
+ Questo server è già configurato
+
+
+ Ha già un amministratore: accedi, oppure unisciti con l'invito di un gruppo.
+
+
+ Unisciti con un invito
+
+
+ Questo server non ha ancora un amministratore. Il primo accesso creato qui lo diventa, con la sua prima persona; dopo, per unirsi serve l'invito di un gruppo.
+
+
+ Il tuo accesso
+
+
+ Ripeti la password
+
+
+ La tua prima persona
+
+
+ Gli altri vedono le tue persone, mai il tuo accesso.
+
+
+ Le due password non coincidono.
+
+
+ PrivaPub è configurato: ne sei l'amministratore.
+
+
+ Una tua persona si unisce a un gruppo con il suo invito, in Gruppi.
+
+
+ L'invito di un gruppo crea qui il tuo accesso, e la tua prima persona entra nel gruppo.
+
+
+ Ho già un accesso: entra
+
+
+ Ti diamo il benvenuto: la tua persona è nel gruppo.
+
+
+ Questo server non ha ancora un amministratore
+
+
+ Configuralo: il primo accesso creato ne diventa l'amministratore.
+
+
+ Hai un invito?
+
+
+ Questo accesso non ha ancora una persona con cui agire: creane una nelle Impostazioni.
+
+
+ Link d'invito, per chi non ha un accesso qui
+
diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx
index 04b6803..0779e43 100644
--- a/Resources/AllStrings.resx
+++ b/Resources/AllStrings.resx
@@ -425,9 +425,6 @@
This device did not give its position: {0}
-
- This login has no persona to act as. Create one on PrivaPub first.
-
This persona is in no community or circle yet.
@@ -2246,4 +2243,67 @@
servers blocked
+
+ Set up PrivaPub
+
+
+ You are signed in
+
+
+ This server is set up
+
+
+ It has an administrator already: sign in, or join with a group's invitation.
+
+
+ Join with an invitation
+
+
+ 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.
+
+
+ Your login
+
+
+ Repeat the password
+
+
+ Your first persona
+
+
+ Others see your personas, never your login.
+
+
+ The two passwords don't match.
+
+
+ PrivaPub is set up: you are its administrator.
+
+
+ A persona of yours joins a group by its invitation in Groups.
+
+
+ A group's invitation makes your login here, and your first persona joins the group.
+
+
+ I have a login: sign in
+
+
+ Welcome: your persona is in the group.
+
+
+ This server has no administrator yet
+
+
+ Set it up: the first login made becomes its administrator.
+
+
+ Have an invitation?
+
+
+ This login has no persona to act as yet: make one in Settings.
+
+
+ Invitation link, for someone without a login here
+
diff --git a/Services/AuthService.cs b/Services/AuthService.cs
index 5b49da8..194937a 100644
--- a/Services/AuthService.cs
+++ b/Services/AuthService.cs
@@ -28,6 +28,39 @@ namespace decePubClient.Services
public async Task Login(LoginForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
+ return await StartSession(response, cancellationToken);
+ }
+
+ /// Whether the server waits for its first login, which becomes its administrator; false when it can't say.
+ public async Task SetupPending(CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.GetTotallyAnon(APIs.ClientApi.GET_Setup, cancellationToken: cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ await LogFailure(response, nameof(SetupPending), cancellationToken);
+ return false;
+ }
+
+ return (await response.DefaultReadFromJsonAsync(cancellationToken))?.IsPending ?? false;
+ }
+
+ /// Makes the server's first login, its administrator, with its first persona, and signs it in.
+ public async Task SetUp(SetupForm form, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Setup, form, cancellationToken: cancellationToken);
+ return await StartSession(response, cancellationToken);
+ }
+
+ /// Makes a login through a group's invitation, its first persona a member of the group, and signs it in.
+ public async Task Join(InvitationLoginForm form, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_InvitationSignUp, form, cancellationToken: cancellationToken);
+ return await StartSession(response, cancellationToken);
+ }
+
+ /// Keeps the JWT a login, a setup or an invitation answered with.
+ async Task StartSession(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
diff --git a/tests/e2e/Login/SignUpTests.cs b/tests/e2e/Login/SignUpTests.cs
new file mode 100644
index 0000000..842cfc3
--- /dev/null
+++ b/tests/e2e/Login/SignUpTests.cs
@@ -0,0 +1,131 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+
+using decePubClient.E2E.Support;
+
+using Microsoft.Playwright;
+
+namespace decePubClient.E2E.Login
+{
+ // The two ways in now that sign-up is closed: the server's first login, made from the client while it has no
+ // administrator, and a login made through a group's invitation
+ public sealed class SignUpTests(Browser browser)
+ {
+ const string Password = "Pasture-Pass-1!";
+
+ static async Task SetupPending()
+ {
+ using var http = Pasture.Http();
+ return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue();
+ }
+
+ static async Task Jwt(string root, string password)
+ {
+ using var http = Pasture.Http();
+ using var login = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
+ login.EnsureSuccessStatusCode();
+ return (await login.Content.ReadFromJsonAsync())!["token"]!.GetValue();
+ }
+
+ static async Task Send(HttpMethod method, string path, string jwt, object body = null)
+ {
+ using var http = Pasture.Http();
+ using var request = new HttpRequestMessage(method, Pasture.Api + path) { Content = body is null ? null : JsonContent.Create(body) };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
+ using var response = await http.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+ return JsonNode.Parse(await response.Content.ReadAsStringAsync());
+ }
+
+ [Fact]
+ public async Task The_first_login_sets_the_server_up_and_becomes_its_administrator()
+ {
+ var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
+ var root = $"e2eadmin{stamp}";
+ var persona = $"e2efounder{stamp}";
+ // the pasture's administrator closed setup: it is opened for this test, and closed again whatever happens
+ await Pasture.Mongo("db.ServerSetup.deleteOne({_id: 'setup'})");
+ try
+ {
+ Assert.True(await SetupPending());
+ await using var session = await browser.Open();
+ var page = session.Page;
+ await Results.Cell("client.action.setup", "privapub", root, async () =>
+ {
+ await page.GotoAsync($"{Pasture.Site}/login");
+ await page.Locator("[data-setup-offer]").ClickAsync();
+ await page.Locator("input[data-setup-username]").FillAsync(root);
+ await page.Locator("input[data-setup-password]").FillAsync(Password);
+ await page.Locator("input[data-setup-repeat]").FillAsync(Password);
+ await page.Locator("input[data-setup-persona-username]").FillAsync(persona);
+ await page.Locator("input[data-setup-persona-name]").FillAsync("The e2e founder");
+ await page.Locator("[data-setup-submit]").ClickAsync();
+
+ await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
+ await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
+ await page.Locator("nav a[href='administration']").First.WaitForAsync(new() { Timeout = 15000 });
+ Assert.False(await SetupPending());
+ var jwt = await Jwt(root, Password);
+ var personas = (await Send(HttpMethod.Get, "/clientapi/avatar/private/list", jwt))!.AsArray();
+ Assert.Equal(persona, Assert.Single(personas)!["userName"]!.GetValue());
+ await session.AssertGuards();
+
+ // set up: a reader without a login is offered sign-in and invitations only
+ await using var stranger = await browser.Open();
+ await stranger.Page.GotoAsync($"{Pasture.Site}/setup");
+ await stranger.Page.Locator("[data-setup-done]").WaitForAsync(new() { Timeout = 15000 });
+ await stranger.Page.GotoAsync($"{Pasture.Site}/login");
+ await stranger.Page.Locator("[data-join-offer]").WaitForAsync(new() { Timeout = 15000 });
+ Assert.Equal(0, await stranger.Page.Locator("[data-setup-offer]").CountAsync());
+ }, page);
+ }
+ finally
+ {
+ // the pasture's administrator stays pastureroot: the test's is demoted, and setup stays closed
+ await Pasture.Mongo($"db.RootUser.updateOne({{UserName: '{root}'}}, {{$set: {{Policies: ['isUser']}}}}); " +
+ "if (!db.ServerSetup.findOne({_id: 'setup'})) db.ServerSetup.insertOne({_id: 'setup', AdminId: 'e2e', SealedAt: new Date()})");
+ }
+ }
+
+ [Fact]
+ public async Task An_invitation_link_makes_a_login_whose_persona_joins_the_group()
+ {
+ var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
+ // the circle's owner is a login of the test's own (the pasture's sign-up is open), so the town's sessions don't matter
+ var ownerJwt = default(string);
+ using (var http = Pasture.Http())
+ {
+ using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = $"e2eowner{stamp}", password = Password });
+ Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
+ ownerJwt = (await signup.Content.ReadFromJsonAsync())!["token"]!.GetValue();
+ }
+ var ownerId = (await Send(HttpMethod.Post, "/clientapi/avatar/private/insert", ownerJwt,
+ new { userName = $"e2ehost{stamp}", name = "An e2e host", biography = "for the invitation test" }))!["id"]!.GetValue();
+ var group = await Send(HttpMethod.Post, "/clientapi/group/insert", ownerJwt,
+ new { avatarId = ownerId, userName = $"e2ecircle{stamp}", name = "An e2e circle", isCommunity = false });
+ var code = group!["invitationCode"]!.GetValue();
+ var root = $"e2ejoin{stamp}";
+ var persona = $"e2eguest{stamp}";
+ await using var session = await browser.Open();
+ var page = session.Page;
+ await Results.Cell("client.action.invitation", "privapub", root, async () =>
+ {
+ await page.GotoAsync($"{Pasture.Site}/join?code={Uri.EscapeDataString(code)}");
+ Assert.Equal(code, await page.Locator("input[data-join-code]").InputValueAsync());
+ await page.Locator("input[data-join-username]").FillAsync(root);
+ await page.Locator("input[data-join-password]").FillAsync(Password);
+ await page.Locator("input[data-join-repeat]").FillAsync(Password);
+ await page.Locator("input[data-join-persona-username]").FillAsync(persona);
+ await page.Locator("[data-join-submit]").ClickAsync();
+
+ await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
+ await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
+ var members = (await Send(HttpMethod.Get,
+ $"/clientapi/group/members?avatarId={Uri.EscapeDataString(ownerId)}&groupId={Uri.EscapeDataString(group["id"]!.GetValue())}", ownerJwt))!.AsArray();
+ Assert.Contains(members, m => (m!["handle"]?.GetValue() ?? "").Contains(persona) || m["name"]?.GetValue() == persona);
+ await session.AssertGuards();
+ }, page);
+ }
+ }
+}
diff --git a/tests/e2e/Mobile/Routes.cs b/tests/e2e/Mobile/Routes.cs
index 6cf1bf7..caba068 100644
--- a/tests/e2e/Mobile/Routes.cs
+++ b/tests/e2e/Mobile/Routes.cs
@@ -72,6 +72,8 @@ namespace decePubClient.E2E.Mobile
await OpenEveryCollapse(page);
}, () => Admin, Screens: 30),
new("login", async (page, _) => await Visit(page, "/login"), SignedOut: true),
+ new("setup", async (page, _) => await Visit(page, "/setup"), SignedOut: true),
+ new("join", async (page, _) => await Visit(page, "/join"), SignedOut: true),
new("styleguide", async (page, _) => await Visit(page, "/styleguide"), SignedOut: true, Screens: 40, Gating: false)
];