From 5796d62406e5deafc36942aa5273c331f9b11771 Mon Sep 17 00:00:00 2001 From: thepra Date: Fri, 16 Oct 2026 23:21:48 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 8 ++ ClientJsonContext.cs | 3 + Models/APIs.cs | 3 + Pages/Groups.razor | 3 + Pages/Join.razor | 123 +++++++++++++++++++++++++++++ Pages/Login.razor | 90 +++++++++++++++------- Pages/Setup.razor | 137 +++++++++++++++++++++++++++++++++ Resources/AllStrings.it.resx | 66 +++++++++++++++- Resources/AllStrings.resx | 66 +++++++++++++++- Services/AuthService.cs | 33 ++++++++ tests/e2e/Login/SignUpTests.cs | 131 +++++++++++++++++++++++++++++++ tests/e2e/Mobile/Routes.cs | 2 + 12 files changed, 630 insertions(+), 35 deletions(-) create mode 100644 Pages/Join.razor create mode 100644 Pages/Setup.razor create mode 100644 tests/e2e/Login/SignUpTests.cs 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."]

+ + + + + @Localizer["Your login"] + + + + + @Localizer["Your first persona"] +

@Localizer["Others see your personas, never your login."]

+ + @@ + + + + + + + @if (isWorking) + { + + } + @Localizer["Join"] + + @Localizer["I have a login: sign in"] +
+ } + +
+ +
+ +@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."]

+
+ @if (setupPending) + { + + + + @Localizer["This server has no administrator yet"] + @Localizer["Set it up: the first login made becomes its administrator."] + + + } + + +

@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."]

- - + + - + - - @if (isWorking) - { - - } - @Localizer["Login"] - -
+ + @if (isWorking) + { + + } + @Localizer["Login"] + + +

+ @Localizer["Have an invitation?"] @Localizer["Join with an invitation"] +

+
} else if (CascadingState.Personas.Count == 0) { -
+

@Localizer["No persona yet"]

-

@Localizer["This login has no persona to act as. Create one on PrivaPub first."]

+

@Localizer["This login has no persona to act as yet: make one in Settings."]

+ @Localizer["Settings"] @Localizer["Logout"]
} @@ -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() { diff --git a/Pages/Setup.razor b/Pages/Setup.razor new file mode 100644 index 0000000..bc21a0a --- /dev/null +++ b/Pages/Setup.razor @@ -0,0 +1,137 @@ +@page "/setup" +@inherits LocalizableComponentBase +@Localizer["Set up PrivaPub"] + +@* 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). *@ +
+ +
+ + @if (IsLoading) + { + + } + else if (CascadingState.IsSignedIn) + { +
+

@Localizer["You are signed in"]

+ @Localizer["Home"] +
+ } + else if (!isPending) + { +
+

@Localizer["This server is set up"]

+

@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."]

+ + @Localizer["Your login"] + + + + + @Localizer["Your first persona"] +

@Localizer["Others see your personas, never your login."]

+ + @@ + + + + + + + @if (isWorking) + { + + } + @Localizer["Set up PrivaPub"] + +
+ } + +
+ +
+ +@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) ];