Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5796d62406 | ||
|
|
4da6a398d7 |
No files matched your search
@@ -154,6 +154,9 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
||||
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||
shadow with `shadow-none!`.
|
||||
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
|
||||
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
|
||||
file input's own button (Choose File, Browse…) is raised like the app's buttons.
|
||||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||||
every collapse disappears.
|
||||
|
||||
@@ -168,7 +171,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
||||
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
||||
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
||||
daisyUI's own size variables:
|
||||
- buttons, tabs, inputs and selects are 44px tall;
|
||||
- buttons, tabs, inputs, selects and file inputs are 44px tall;
|
||||
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
|
||||
the padding box);
|
||||
- checkboxes and radios are 24px, toggles and range thumbs 28;
|
||||
@@ -293,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,
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
+37
-5
@@ -3,14 +3,26 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -38,12 +50,17 @@
|
||||
<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("");
|
||||
}
|
||||
}
|
||||
@@ -425,9 +425,6 @@
|
||||
<data name="This device did not give its position: {0}" xml:space="preserve">
|
||||
<value>Questo dispositivo non ha dato la sua posizione: {0}</value>
|
||||
</data>
|
||||
<data name="This login has no persona to act as. Create one on PrivaPub first." xml:space="preserve">
|
||||
<value>Questo accesso non ha una persona con cui agire. Creane prima una su PrivaPub.</value>
|
||||
</data>
|
||||
<data name="This persona is in no community or circle yet." xml:space="preserve">
|
||||
<value>Questa persona non è ancora in nessuna comunità o cerchia.</value>
|
||||
</data>
|
||||
@@ -2246,4 +2243,67 @@
|
||||
<data name="servers blocked" xml:space="preserve">
|
||||
<value>server bloccati</value>
|
||||
</data>
|
||||
<data name="Set up PrivaPub" xml:space="preserve">
|
||||
<value>Configura PrivaPub</value>
|
||||
</data>
|
||||
<data name="You are signed in" xml:space="preserve">
|
||||
<value>Hai già effettuato l'accesso</value>
|
||||
</data>
|
||||
<data name="This server is set up" xml:space="preserve">
|
||||
<value>Questo server è già configurato</value>
|
||||
</data>
|
||||
<data name="It has an administrator already: sign in, or join with a group's invitation." xml:space="preserve">
|
||||
<value>Ha già un amministratore: accedi, oppure unisciti con l'invito di un gruppo.</value>
|
||||
</data>
|
||||
<data name="Join with an invitation" xml:space="preserve">
|
||||
<value>Unisciti con un invito</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Your login" xml:space="preserve">
|
||||
<value>Il tuo accesso</value>
|
||||
</data>
|
||||
<data name="Repeat the password" xml:space="preserve">
|
||||
<value>Ripeti la password</value>
|
||||
</data>
|
||||
<data name="Your first persona" xml:space="preserve">
|
||||
<value>La tua prima persona</value>
|
||||
</data>
|
||||
<data name="Others see your personas, never your login." xml:space="preserve">
|
||||
<value>Gli altri vedono le tue persone, mai il tuo accesso.</value>
|
||||
</data>
|
||||
<data name="The two passwords don't match." xml:space="preserve">
|
||||
<value>Le due password non coincidono.</value>
|
||||
</data>
|
||||
<data name="PrivaPub is set up: you are its administrator." xml:space="preserve">
|
||||
<value>PrivaPub è configurato: ne sei l'amministratore.</value>
|
||||
</data>
|
||||
<data name="A persona of yours joins a group by its invitation in Groups." xml:space="preserve">
|
||||
<value>Una tua persona si unisce a un gruppo con il suo invito, in Gruppi.</value>
|
||||
</data>
|
||||
<data name="A group's invitation makes your login here, and your first persona joins the group." xml:space="preserve">
|
||||
<value>L'invito di un gruppo crea qui il tuo accesso, e la tua prima persona entra nel gruppo.</value>
|
||||
</data>
|
||||
<data name="I have a login: sign in" xml:space="preserve">
|
||||
<value>Ho già un accesso: entra</value>
|
||||
</data>
|
||||
<data name="Welcome: your persona is in the group." xml:space="preserve">
|
||||
<value>Ti diamo il benvenuto: la tua persona è nel gruppo.</value>
|
||||
</data>
|
||||
<data name="This server has no administrator yet" xml:space="preserve">
|
||||
<value>Questo server non ha ancora un amministratore</value>
|
||||
</data>
|
||||
<data name="Set it up: the first login made becomes its administrator." xml:space="preserve">
|
||||
<value>Configuralo: il primo accesso creato ne diventa l'amministratore.</value>
|
||||
</data>
|
||||
<data name="Have an invitation?" xml:space="preserve">
|
||||
<value>Hai un invito?</value>
|
||||
</data>
|
||||
<data name="This login has no persona to act as yet: make one in Settings." xml:space="preserve">
|
||||
<value>Questo accesso non ha ancora una persona con cui agire: creane una nelle Impostazioni.</value>
|
||||
</data>
|
||||
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
||||
<value>Link d'invito, per chi non ha un accesso qui</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -425,9 +425,6 @@
|
||||
<data name="This device did not give its position: {0}" xml:space="preserve">
|
||||
<value>This device did not give its position: {0}</value>
|
||||
</data>
|
||||
<data name="This login has no persona to act as. Create one on PrivaPub first." xml:space="preserve">
|
||||
<value>This login has no persona to act as. Create one on PrivaPub first.</value>
|
||||
</data>
|
||||
<data name="This persona is in no community or circle yet." xml:space="preserve">
|
||||
<value>This persona is in no community or circle yet.</value>
|
||||
</data>
|
||||
@@ -2246,4 +2243,67 @@
|
||||
<data name="servers blocked" xml:space="preserve">
|
||||
<value>servers blocked</value>
|
||||
</data>
|
||||
<data name="Set up PrivaPub" xml:space="preserve">
|
||||
<value>Set up PrivaPub</value>
|
||||
</data>
|
||||
<data name="You are signed in" xml:space="preserve">
|
||||
<value>You are signed in</value>
|
||||
</data>
|
||||
<data name="This server is set up" xml:space="preserve">
|
||||
<value>This server is set up</value>
|
||||
</data>
|
||||
<data name="It has an administrator already: sign in, or join with a group's invitation." xml:space="preserve">
|
||||
<value>It has an administrator already: sign in, or join with a group's invitation.</value>
|
||||
</data>
|
||||
<data name="Join with an invitation" xml:space="preserve">
|
||||
<value>Join with an invitation</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Your login" xml:space="preserve">
|
||||
<value>Your login</value>
|
||||
</data>
|
||||
<data name="Repeat the password" xml:space="preserve">
|
||||
<value>Repeat the password</value>
|
||||
</data>
|
||||
<data name="Your first persona" xml:space="preserve">
|
||||
<value>Your first persona</value>
|
||||
</data>
|
||||
<data name="Others see your personas, never your login." xml:space="preserve">
|
||||
<value>Others see your personas, never your login.</value>
|
||||
</data>
|
||||
<data name="The two passwords don't match." xml:space="preserve">
|
||||
<value>The two passwords don't match.</value>
|
||||
</data>
|
||||
<data name="PrivaPub is set up: you are its administrator." xml:space="preserve">
|
||||
<value>PrivaPub is set up: you are its administrator.</value>
|
||||
</data>
|
||||
<data name="A persona of yours joins a group by its invitation in Groups." xml:space="preserve">
|
||||
<value>A persona of yours joins a group by its invitation in Groups.</value>
|
||||
</data>
|
||||
<data name="A group's invitation makes your login here, and your first persona joins the group." xml:space="preserve">
|
||||
<value>A group's invitation makes your login here, and your first persona joins the group.</value>
|
||||
</data>
|
||||
<data name="I have a login: sign in" xml:space="preserve">
|
||||
<value>I have a login: sign in</value>
|
||||
</data>
|
||||
<data name="Welcome: your persona is in the group." xml:space="preserve">
|
||||
<value>Welcome: your persona is in the group.</value>
|
||||
</data>
|
||||
<data name="This server has no administrator yet" xml:space="preserve">
|
||||
<value>This server has no administrator yet</value>
|
||||
</data>
|
||||
<data name="Set it up: the first login made becomes its administrator." xml:space="preserve">
|
||||
<value>Set it up: the first login made becomes its administrator.</value>
|
||||
</data>
|
||||
<data name="Have an invitation?" xml:space="preserve">
|
||||
<value>Have an invitation?</value>
|
||||
</data>
|
||||
<data name="This login has no persona to act as yet: make one in Settings." xml:space="preserve">
|
||||
<value>This login has no persona to act as yet: make one in Settings.</value>
|
||||
</data>
|
||||
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
||||
<value>Invitation link, for someone without a login here</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -28,6 +28,39 @@ namespace decePubClient.Services
|
||||
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
|
||||
return await StartSession(response, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Whether the server waits for its first login, which becomes its administrator; false when it can't say.</summary>
|
||||
public async Task<bool> 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<ViewServerSetup>(cancellationToken))?.IsPending ?? false;
|
||||
}
|
||||
|
||||
/// <summary>Makes the server's first login, its administrator, with its first persona, and signs it in.</summary>
|
||||
public async Task<WebResult> SetUp(SetupForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Setup, form, cancellationToken: cancellationToken);
|
||||
return await StartSession(response, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Makes a login through a group's invitation, its first persona a member of the group, and signs it in.</summary>
|
||||
public async Task<WebResult> Join(InvitationLoginForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_InvitationSignUp, form, cancellationToken: cancellationToken);
|
||||
return await StartSession(response, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Keeps the JWT a login, a setup or an invitation answered with.</summary>
|
||||
async Task<WebResult> StartSession(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
|
||||
+35
-2
@@ -210,21 +210,54 @@
|
||||
|
||||
&:not(:where(.file-input-ghost, .file-input-neutral, .file-input-primary, .file-input-secondary, .file-input-accent, .file-input-info, .file-input-success, .file-input-warning, .file-input-error, .validator:user-invalid, [aria-invalid="true"])) {
|
||||
border-color: transparent;
|
||||
|
||||
/* its button (Choose File, Browse…) is a raised button like every other, not daisyUI's flat neutral block; a
|
||||
small gap keeps its shadow inside the well */
|
||||
&::file-selector-button {
|
||||
margin-block: .25rem;
|
||||
margin-inline-start: .25rem;
|
||||
height: calc(100% - .5rem);
|
||||
border: none;
|
||||
border-radius: calc(var(--radius-box) - .25rem);
|
||||
background-color: var(--color-base-100);
|
||||
background-image: linear-gradient(145deg, color-mix(in oklab, var(--color-base-100), white var(--neo-lift)), color-mix(in oklab, var(--color-base-100), black var(--neo-sink)));
|
||||
box-shadow: -2px -2px 4px var(--neo-light), 2px 2px 4px var(--neo-dark);
|
||||
color: var(--color-base-content);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Selectors: an inset well while unchecked; once checked they keep daisyUI's filled look. */
|
||||
/* Selectors: an inset well while unchecked. A checked box is filled like a button of the primary colour, raised, its
|
||||
tick in the colour's content (daisyUI fills one only for a colour variant: without, a tick on nothing); a checked
|
||||
radio keeps the well, its dot the primary colour. A colour variant keeps its own colour. */
|
||||
@utility checkbox {
|
||||
&:not(:where(.checkbox-neutral, .checkbox-primary, .checkbox-secondary, .checkbox-accent, .checkbox-info, .checkbox-success, .checkbox-warning, .checkbox-error)) {
|
||||
--input-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
&:not(:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"], :disabled)) {
|
||||
border-color: transparent;
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
}
|
||||
|
||||
&:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"]):not(:disabled) {
|
||||
border-color: transparent;
|
||||
background-image: linear-gradient(145deg, color-mix(in oklab, var(--input-color), white var(--neo-lift)), color-mix(in oklab, var(--input-color), black var(--neo-sink)));
|
||||
background-origin: border-box;
|
||||
box-shadow: -2px -2px 4px var(--neo-light), 2px 2px 4px var(--neo-dark);
|
||||
}
|
||||
}
|
||||
|
||||
@utility radio {
|
||||
&:not(:where(:checked, [aria-checked="true"], :disabled)) {
|
||||
&:not(:where(.radio-neutral, .radio-primary, .radio-secondary, .radio-accent, .radio-info, .radio-success, .radio-warning, .radio-error)) {
|
||||
--input-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:not(:where(:disabled)) {
|
||||
border-color: transparent;
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
|
||||
+2
-1
@@ -89,7 +89,8 @@
|
||||
}
|
||||
|
||||
.input:not(.input-lg, .input-xl),
|
||||
.select:not(.select-lg, .select-xl) {
|
||||
.select:not(.select-lg, .select-xl),
|
||||
.file-input:not(.file-input-lg, .file-input-xl) {
|
||||
--size: 2.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> SetupPending()
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue<bool>();
|
||||
}
|
||||
|
||||
static async Task<string> 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<JsonObject>())!["token"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
static async Task<JsonNode> 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<string>());
|
||||
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<JsonObject>())!["token"]!.GetValue<string>();
|
||||
}
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>())}", ownerJwt))!.AsArray();
|
||||
Assert.Contains(members, m => (m!["handle"]?.GetValue<string>() ?? "").Contains(persona) || m["name"]?.GetValue<string>() == persona);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// Controls the sweep doesn't reach, since it measures pages with their sections closed: Settings' Data import/export
|
||||
// opened on the narrowest phone, its file inputs and checkboxes thumb-sized and drawn like the rest of the app (a
|
||||
// ticked box filled, not a tick on nothing), in both themes. Photos go to tests/e2e/out/mobile-controls/.
|
||||
[Trait("Category", "Mobile")]
|
||||
public sealed class ControlsTests(Browser browser)
|
||||
{
|
||||
static readonly Phone Phone = Phone.Narrow360;
|
||||
static readonly string OutDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "out", "mobile-controls");
|
||||
|
||||
[Fact]
|
||||
public async Task Data_transfer_controls_are_thumb_sized_and_drawn_like_the_rest()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Pasture.Personas().First(), Phone);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.mobile.controls.data-transfer", "privapub", Phone.Key, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
||||
await header.TapAsync();
|
||||
var archive = page.Locator("[data-archive]");
|
||||
await archive.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
Fit.AssertTouchable(await Fit.Now(page, "[data-archive]"));
|
||||
Fit.AssertTouchable(await Fit.Now(page, "[data-import]"));
|
||||
|
||||
// a ticked box is filled: daisyUI leaves it transparent unless a colour asks otherwise
|
||||
var ticked = archive.Locator("input[type=checkbox]:checked").First;
|
||||
var fill = await ticked.EvaluateAsync<string>("e => getComputedStyle(e).backgroundColor");
|
||||
Assert.False(fill is "transparent" or "rgba(0, 0, 0, 0)", $"a ticked checkbox is drawn on nothing ({fill})");
|
||||
// a file input is as tall as the other fields on a touch screen
|
||||
foreach (var input in await page.Locator("[data-archive] input[type=file], [data-import] input[type=file]").AllAsync())
|
||||
Assert.True((await input.BoundingBoxAsync())!.Height >= 44, $"a file input is {(await input.BoundingBoxAsync())!.Height}px tall: "
|
||||
+ await input.EvaluateAsync<string>("e => { const s = getComputedStyle(e); return `${e.className} --size=${s.getPropertyValue('--size')} height=${s.height} coarse=${matchMedia('(pointer: coarse)').matches}`; }"));
|
||||
// photos the size of the screen: one of an element taller than the screen makes Playwright resize the page,
|
||||
// which drops the touch emulation for what comes after
|
||||
Directory.CreateDirectory(OutDir);
|
||||
foreach (var theme in new[] { "neo-dark", "neo-light" })
|
||||
{
|
||||
await page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme);
|
||||
foreach (var (part, what) in new[] { ("archive", "[data-archive-make]"), ("restore", "input[data-archive-file]"), ("csv", "[data-import]") })
|
||||
{
|
||||
await page.Locator(what).First.EvaluateAsync("e => e.scrollIntoView({ block: 'start' })");
|
||||
await page.ScreenshotAsync(new() { Path = Path.Combine(OutDir, $"{part}-{theme}.png"), Animations = ScreenshotAnimations.Disabled });
|
||||
}
|
||||
}
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user