R2 accounts in decePub: forgotten password, email by code, sessions, the theme kept with the login

Completion program R2, with PrivaPub's routes of the same release.

- Pages/PasswordRecovery.razor (/password-recovery): asks for a login or an address and shows the server's one
  sentence; with ?rc= it takes the code out of the address bar at once, checks it and sets a new password. Login links
  there ("Forgot your password?").
- Settings, the login: its email address with a verified badge, changed with the password and the code emailed to it
  (DOtp), removed with the password; "Sign out everywhere" (every device and persona, asked first).
- A password change takes the new JWT PrivaPub answers and exchanges the persona's token again, since every persona
  token ended with it.
- The theme kept with the login (PageSettings.SyncWithLogin, Settings > General): taken at sign-in and start-up once a
  client saved it, saved 1.5 s after a change here, never posted back when nothing changed, never the language.
- Login, Setup and Join start the session through CascadingState.SignedIn, which takes the login's theme.
- 38 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 03:21:59 +02:00
1 parent fd2f2e7c65
commit 7d4b35f0d7
15 files changed
+1056 -17

No files matched your search

+31 -7
View File
@@ -101,7 +101,14 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
- picking a hue leaves grey. - picking a hue leaves grey.
- `AppStatusService.IsDarkTheme()` tells which variant is showing. - `AppStatusService.IsDarkTheme()` tells which variant is showing.
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones. - Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring). The other sections of Settings belong to the login or the persona (see **Settings** under PrivaPub wiring).
- **The theme is the login's** while `PageSettings.SyncWithLogin` is on (the default; Settings → General turns it off
for this device). `CascadingState` takes the login's theme (`ViewUserSettings`; never its language, which would reload
the app) at sign-in from the `JwtUser` (`SignedIn`, which Login, Setup and Join call instead of `LoadSession`) and at
start-up from `/clientapi/user/settings`, once `savedAt` says a client saved one; while none did, it gives the login
this device's. `UpdatePageSettings` saves them to `/clientapi/user/update/settings` 1.5 s after the last change, one
save waiting at a time, only when they differ from what the login is known to keep (so taking them never sends them
back), with the language as two letters and the icons' hue at 25; a failure is logged, never shown.
- `Styles/neo.css` holds the neumorphic layer: - `Styles/neo.css` holds the neumorphic layer:
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the - `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
old `neomorph is-nxsmall` classes); old `neomorph is-nxsmall` classes);
@@ -232,8 +239,8 @@ Never redeclare the base's members in a component.
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload `SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt. can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses - Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
PrivaPub redirects it). not carry a JWT: PrivaPub redirects them).
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the - Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
`HttpClient`'s own is off. `HttpClient`'s own is off.
@@ -250,7 +257,13 @@ Never redeclare the base's members in a component.
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then - **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`, 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 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. persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
forgets the session.
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
its own and signs out. A password change ends every other session and revokes every persona token, this device's
too: `AuthService.ChangePassword` keeps the new `JwtUser` PrivaPub answers with, drops the stored persona tokens and
exchanges one for the current persona at once (the others when next used), so the device stays signed in.
- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16): - **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 - **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 login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
@@ -259,6 +272,13 @@ Never redeclare the base's members in a component.
the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code. 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 - 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. persona is sent from Login to Settings to make one.
- **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's
name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the
same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified
address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar
and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new
password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in.
All of it goes with no credentials.
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; - **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 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, `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
@@ -338,9 +358,13 @@ Never redeclare the base's members in a component.
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a - **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error. once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed, - **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; (`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
the client then signs out), the persona's profile (`ProfileEditor`: then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
such; its password changed; every session of it ended ("Sign out everywhere", see **One sign-in**); or the whole of it
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
signs out), the persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file, `update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot, and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
+10
View File
@@ -35,6 +35,16 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewServerSetup))] [JsonSerializable(typeof(ViewServerSetup))]
[JsonSerializable(typeof(UserPasswordForm))] [JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))] [JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(PasswordRecoveryForm))]
[JsonSerializable(typeof(NewPasswordForm))]
[JsonSerializable(typeof(ViewAccount))]
[JsonSerializable(typeof(EmailChangeForm))]
[JsonSerializable(typeof(EmailCodeForm))]
[JsonSerializable(typeof(PasswordForm))]
[JsonSerializable(typeof(ViewUserSettings))]
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(JwtUser))] [JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))] [JsonSerializable(typeof(List<ViewAvatar>))]
+239 -3
View File
@@ -1,11 +1,111 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@* The login itself (the root, /clientapi/user): its password changed, or the whole of it deleted, every persona with @* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
it. Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@ new one, since a forgotten password is recovered through it; its password changed (every other session ends, this
device's goes on with a new JWT); every session of it ended at once; or the whole of it deleted, every persona with it.
Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
<div class="flex flex-col gap-4 w-full" data-account> <div class="flex flex-col gap-4 w-full" data-account>
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email>
<b class="text-sm">@Localizer["Email address"]</b>
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (account is not null)
{
@if (account.Email is { Length: > 0 } email)
{
<p class="flex flex-wrap items-center gap-2 text-sm">
<span class="break-all" data-account-email-address>@email</span>
@if (account.IsEmailValidated)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-account-email-verified>@Localizer["verified"]</DBadge>
}
else
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-email-unverified>@Localizer["not verified"]</DBadge>
}
</p>
@if (!account.IsEmailValidated)
{
<p class="text-xs opacity-70">@Localizer["Only a verified address can recover a forgotten password."]</p>
}
}
else
{
<p class="text-sm" data-account-email-none>@Localizer["No email address yet: without one, a forgotten password can't be recovered."]</p>
}
@if (account.PendingEmail is { Length: > 0 } pending && !isChangingEmail)
{
<EditForm Model="code" OnValidSubmit="VerifyEmail" class="flex flex-col gap-2" data-account-email-code>
<DataAnnotationsValidator />
@if (codeSentence is not null)
{
<p class="text-sm opacity-70">@codeSentence</p>
}
<p class="text-sm break-all">@Localizer["Enter the code sent to {0}.", pending]</p>
<DOtp @bind-Value="code.Code" Immediate="true" aria-label="@Localizer["Code"]" data-account-email-code-digits />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-verify>
<i class="ion-md-checkmark"></i><span>@Localizer["Verify"]</span>
</DButton>
</EditForm>
}
@if (isChangingEmail)
{
<EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change>
<DataAnnotationsValidator />
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" autocomplete="email" inputmode="email" autocapitalize="none"
spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new />
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-email-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-send>
<i class="ion-md-mail"></i><span>@Localizer["Send the code"]</span>
</DButton>
</div>
</EditForm>
}
else if (isRemovingEmail)
{
<EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-email-remove-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-remove-confirm>
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@if (account.Email is { Length: > 0 })
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailRemoval" data-account-email-remove-open>
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailChange" data-account-email-change-open>
<i class="ion-md-create"></i><span>@(account.Email is { Length: > 0 } ? Localizer["Change"] : Localizer["Add an address"])</span>
</DButton>
</div>
}
}
</div>
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password> <EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<b class="text-sm">@Localizer["Change the password"]</b> <b class="text-sm">@Localizer["Change the password"]</b>
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password" <DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-current /> placeholder="@Localizer["Current password"]" data-account-current />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -20,6 +120,14 @@
</DButton> </DButton>
</EditForm> </EditForm>
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere>
<b class="text-sm">@Localizer["Sign out everywhere"]</b>
<p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p>
<DButton Size="DSize.Sm" Color="DColor.Warning" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="LogoutEverywhere" data-account-everywhere-confirm>
<i class="ion-md-log-out"></i><span>@Localizer["Sign out everywhere"]</span>
</DButton>
</div>
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete> <EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<b class="text-sm text-error">@Localizer["Delete this login"]</b> <b class="text-sm text-error">@Localizer["Delete this login"]</b>
@@ -36,17 +144,125 @@
@code { @code {
[Inject] AuthService AuthService { get; set; } [Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
ViewAccount account;
EmailChangeForm change = new();
EmailCodeForm code = new();
PasswordForm emailRemoval = new();
UserPasswordForm password = new(); UserPasswordForm password = new();
RemoveSelfForm removal = new(); RemoveSelfForm removal = new();
/// <summary>PrivaPub's answer to the change asked here, the same whether or not the address is free.</summary>
string codeSentence;
bool isChangingEmail;
bool isRemovingEmail;
bool understood; bool understood;
bool isSaving; bool isSaving;
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() => account = await AuthService.Account();
void OpenEmailChange()
{
change = new();
isRemovingEmail = false;
isChangingEmail = true;
}
void OpenEmailRemoval()
{
emailRemoval = new();
isChangingEmail = false;
isRemovingEmail = true;
}
void CloseEmailForms()
{
isChangingEmail = false;
isRemovingEmail = false;
}
async Task ChangeEmail()
{
isSaving = true;
change.Email = change.Email.Trim();
var result = await AuthService.ChangeEmail(change);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
codeSentence = result.Data is string { Length: > 0 } said
? said
: Localizer["If that address can be used, a code is on its way to it. It works for one hour."];
// as PrivaPub keeps it while it waits for its code
account.PendingEmail = change.Email.ToLowerInvariant();
change = new();
code = new();
isChangingEmail = false;
}
async Task VerifyEmail()
{
isSaving = true;
var result = await AuthService.VerifyEmail(code.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
// five wrong codes, or an hour, and the address no longer waits
account = await AuthService.Account() ?? account;
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
code = new();
codeSentence = default;
Toast.AddResponseSuccess(Localizer["Email address verified."]);
}
async Task RemoveEmail()
{
if (!await Confirm.Ask(Localizer["Remove the email address?"], Localizer["Without one, a forgotten password can't be recovered."],
Localizer["Remove the address"], true))
return;
isSaving = true;
var result = await AuthService.RemoveEmail(emailRemoval.Password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
emailRemoval = new();
codeSentence = default;
isRemovingEmail = false;
Toast.AddResponseSuccess(Localizer["Email address removed."]);
}
async Task ChangePassword() async Task ChangePassword()
{ {
isSaving = true; isSaving = true;
// the session goes on with the JWT PrivaPub answers with, and the persona with a new token
var result = await AuthService.ChangePassword(password); var result = await AuthService.ChangePassword(password);
isSaving = false; isSaving = false;
if (result.IsInvalid) if (result.IsInvalid)
@@ -59,6 +275,26 @@
Toast.AddResponseSuccess(Localizer["Password changed."]); Toast.AddResponseSuccess(Localizer["Password changed."]);
} }
async Task LogoutEverywhere()
{
if (!await Confirm.Ask(Localizer["Sign out everywhere?"],
Localizer["Every device and every persona of this login signs out, this one too. Signing in again takes the password."],
Localizer["Sign out everywhere"], true))
return;
isSaving = true;
var result = await AuthService.LogoutEverywhere();
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await CascadingState.SignOut();
Navigation.NavigateTo("login");
}
async Task Delete() async Task Delete()
{ {
isSaving = true; isSaving = true;
+101 -2
View File
@@ -3,7 +3,8 @@
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved @* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
compare PersonaId to reload what belongs to the persona. *@ compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's:
taken at sign-in and at start-up, saved there a moment after it changes here. *@
<CascadingValue IsFixed=false Value=this> <CascadingValue IsFixed=false Value=this>
@ChildContent @ChildContent
</CascadingValue> </CascadingValue>
@@ -11,6 +12,8 @@
@code { @code {
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary> /// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1); static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
/// <summary>How long a change of the page settings waits before it is saved as the login's: a slider sends many.</summary>
static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500);
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
[Inject] ILocalStorageService Storage { get; set; } [Inject] ILocalStorageService Storage { get; set; }
@@ -42,6 +45,11 @@
PeriodicTimer onlineTimer; PeriodicTimer onlineTimer;
PeriodicTimer refreshTimer; PeriodicTimer refreshTimer;
readonly CancellationTokenSource disposeCancellation = new(); readonly CancellationTokenSource disposeCancellation = new();
/// <summary>What the login is known to keep, as LoginFingerprint reads it (null when unknown), so settings that already
/// match it, the login's own just taken among them, aren't saved again.</summary>
(string, short, short, bool, bool, bool, bool)? loginSettings;
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
CancellationTokenSource pendingSettingsSave;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -59,6 +67,8 @@
_ = PollOnline(cancellationToken); _ = PollOnline(cancellationToken);
await LoadSession(cancellationToken); await LoadSession(cancellationToken);
if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin)
await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken);
refreshTimer = new(RefreshInterval); refreshTimer = new(RefreshInterval);
_ = KeepSessionFresh(cancellationToken); _ = KeepSessionFresh(cancellationToken);
} }
@@ -77,13 +87,98 @@
StateHasChanged(); StateHasChanged();
} }
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary> /// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache, and saves them as the
/// login's a moment later (SaveSettingsSoon).</summary>
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default) public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
{ {
change(PublicCacheData.PageSettings); change(PublicCacheData.PageSettings);
SaveSettingsSoon();
return UpdatePublicCache(PublicCacheData, cancellationToken); return UpdatePublicCache(PublicCacheData, cancellationToken);
} }
/// <summary>
/// Reads the session a login, a setup or an invitation has just started (AuthService keeps its JWT, which carries the
/// login's settings), then keeps the theme with the login's (KeepThemeWithLogin).
/// </summary>
public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default)
{
loginSettings = null;
await LoadSession(cancellationToken);
await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken);
}
/// <summary>
/// When this device keeps its theme with the login: takes the login's theme (never its language, which would reload the
/// app) once a client saved one, or gives the login this device's while none did. Unknown settings change nothing.
/// </summary>
async Task KeepThemeWithLogin(ViewUserSettings settings, CancellationToken cancellationToken)
{
if (settings is null || !IsSignedIn || !PublicCacheData.PageSettings.SyncWithLogin)
return;
if (settings.SavedAt is null)
{
SaveSettingsSoon();
return;
}
PublicCacheData.PageSettings.TakeLoginTheme(settings);
// as the login keeps them now, so taking them isn't followed by saving them back
loginSettings = LoginFingerprint(PublicCacheData.PageSettings.ToLoginSettings());
await UpdatePublicCache(PublicCacheData, cancellationToken);
}
/// <summary>
/// Saves the page settings as the login's once SettingsSaveDelay passed without another change: signed in, with
/// SyncWithLogin, and only when they differ from what the login is known to keep. One save waits at a time; a failure
/// is logged (AuthService.SaveSettings), never shown.
/// </summary>
void SaveSettingsSoon()
{
CancelSettingsSave();
var settings = PublicCacheData.PageSettings;
if (!IsSignedIn || !settings.SyncWithLogin || LoginFingerprint(settings.ToLoginSettings()) == loginSettings)
return;
pendingSettingsSave = new();
_ = SaveSettingsLater(pendingSettingsSave.Token);
}
async Task SaveSettingsLater(CancellationToken cancellationToken)
{
try
{
await Task.Delay(SettingsSaveDelay, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
if (!IsSignedIn)
return;
var settings = PublicCacheData.PageSettings.ToLoginSettings();
var result = await AuthService.SaveSettings(settings, cancellationToken);
if (result.IsInvalid)
return;
loginSettings = LoginFingerprint(settings);
}
void CancelSettingsSave()
{
var pending = pendingSettingsSave;
pendingSettingsSave = null;
pending?.Cancel();
pending?.Dispose();
}
/// <summary>What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved.</summary>
static (string, short, short, bool, bool, bool, bool) LoginFingerprint(ViewUserSettings settings) =>
(settings.LanguageCode, settings.LightThemeIndexColour, settings.DarkThemeIndexColour, settings.PreferSystemTheming,
settings.ThemeIsDarkMode, settings.ThemeIsLightGray, settings.ThemeIsDarkGray);
/// <summary> /// <summary>
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and /// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
/// acts as the last one used (the first one otherwise). /// acts as the last one used (the first one otherwise).
@@ -161,6 +256,7 @@
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary> /// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default) public async Task SignOut(CancellationToken cancellationToken = default)
{ {
CancelSettingsSave();
await AuthService.Logout(cancellationToken); await AuthService.Logout(cancellationToken);
ClearSession(); ClearSession();
StateHasChanged(); StateHasChanged();
@@ -168,6 +264,8 @@
void ClearSession() void ClearSession()
{ {
CancelSettingsSave();
loginSettings = null;
IsSignedIn = false; IsSignedIn = false;
RootUserName = null; RootUserName = null;
Personas = []; Personas = [];
@@ -236,6 +334,7 @@
{ {
await disposeCancellation.CancelAsync(); await disposeCancellation.CancelAsync();
disposeCancellation.Dispose(); disposeCancellation.Dispose();
CancelSettingsSave();
onlineTimer?.Dispose(); onlineTimer?.Dispose();
refreshTimer?.Dispose(); refreshTimer?.Dispose();
cascadingStateReference?.Dispose(); cascadingStateReference?.Dispose();
+10
View File
@@ -19,6 +19,16 @@ public static class APIs
public const string GET_Logout = "clientapi/user/logout"; public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again"; public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string POST_UpdatePassword = "clientapi/user/update/password"; public const string POST_UpdatePassword = "clientapi/user/update/password";
public const string POST_LogoutEverywhere = "clientapi/user/logout/everywhere";
public const string POST_RecoverPassword = "clientapi/user/recover/password";
public const string POST_RecoverValid = "clientapi/user/recover/valid";
public const string POST_RecoverUpdatePassword = "clientapi/user/recover/update/password";
public const string GET_Account = "clientapi/user/account";
public const string POST_Email = "clientapi/user/email";
public const string POST_EmailVerify = "clientapi/user/email/verify";
public const string POST_EmailRemove = "clientapi/user/email/remove";
public const string GET_UserSettings = "clientapi/user/settings";
public const string POST_UpdateUserSettings = "clientapi/user/update/settings";
public const string POST_DeleteAccount = "clientapi/user/delete"; public const string POST_DeleteAccount = "clientapi/user/delete";
public const string GET_Personas = "clientapi/avatar/private/list"; public const string GET_Personas = "clientapi/avatar/private/list";
public const string POST_InsertPersona = "clientapi/avatar/private/insert"; public const string POST_InsertPersona = "clientapi/avatar/private/insert";
+31
View File
@@ -1,5 +1,6 @@
using PrivaPub.ClientModels.Resources; using PrivaPub.ClientModels.Resources;
using PrivaPub.ClientModels.User;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
@@ -21,6 +22,8 @@ namespace decePubClient.Models
public bool ThemeIsDarkGray { get; set; } = false; public bool ThemeIsDarkGray { get; set; } = false;
// before publishing pictures without a description, the composer asks once // before publishing pictures without a description, the composer asks once
public bool RemindAltText { get; set; } = true; public bool RemindAltText { get; set; } = true;
// the theme is the login's (PrivaPub's user settings): taken at sign-in, saved there whenever it changes here
public bool SyncWithLogin { get; set; } = true;
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark; // How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the // otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
@@ -56,5 +59,33 @@ namespace decePubClient.Models
LightThemeIndexColour = hue; LightThemeIndexColour = hue;
SetGray(false, isDark); SetGray(false, isDark);
} }
/// <summary>
/// These settings as the login keeps them: the language as the two letters of the culture the app runs in
/// (ExtensionMethods.ToCulture: "it" is it-IT, anything else en-GB), the icons' hue at its default, since the client
/// has no such setting.
/// </summary>
public ViewUserSettings ToLoginSettings() => new()
{
LanguageCode = CurrentLanguageCode is "it" ? "it" : "en",
LightThemeIndexColour = LightThemeIndexColour,
DarkThemeIndexColour = DarkThemeIndexColour,
IconsThemeIndexColour = DefaultLightThemeIndexColour,
PreferSystemTheming = PreferSystemTheming,
ThemeIsDarkMode = ThemeIsDarkMode,
ThemeIsLightGray = ThemeIsLightGray,
ThemeIsDarkGray = ThemeIsDarkGray
};
/// <summary>Takes the login's theme; its language stays this device's, since changing it reloads the app.</summary>
public void TakeLoginTheme(ViewUserSettings settings)
{
LightThemeIndexColour = Math.Clamp(settings.LightThemeIndexColour, (short)0, (short)359);
DarkThemeIndexColour = Math.Clamp(settings.DarkThemeIndexColour, (short)0, (short)359);
PreferSystemTheming = settings.PreferSystemTheming;
ThemeIsDarkMode = settings.ThemeIsDarkMode;
ThemeIsLightGray = settings.ThemeIsLightGray;
ThemeIsDarkGray = settings.ThemeIsDarkGray;
}
} }
} }
+1 -1
View File
@@ -115,7 +115,7 @@
return; return;
} }
await CascadingState.LoadSession(); await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false; isWorking = false;
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]); Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
Navigation.NavigateTo(""); Navigation.NavigateTo("");
+4 -2
View File
@@ -4,7 +4,8 @@
@* 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 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). A server without an administrator offers 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). *@ its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through
the login's verified address (PasswordRecovery). *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <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"> <div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -49,6 +50,7 @@
} }
<span>@Localizer["Login"]</span> <span>@Localizer["Login"]</span>
</DButton> </DButton>
<a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a>
</EditForm> </EditForm>
<p class="text-sm text-center"> <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> @Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
@@ -130,7 +132,7 @@
return; return;
} }
await CascadingState.LoadSession(); await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false; isWorking = false;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null) if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue(); Continue();
+216
View File
@@ -0,0 +1,216 @@
@page "/password-recovery"
@inherits LocalizableComponentBase
<Title>@Localizer["Forgot your password?"]</Title>
@* A forgotten password (PrivaPub's /clientapi/user/recover): the login's name, or its verified address, asks for a link,
and PrivaPub answers everyone with the same sentence, so the page never tells whether a login or an address exists.
The link comes back here with its code (?rc=), which leaves the address bar and the history at once and stays in the
page; a new password set with it ends every session the login had, and the reader signs in with it. *@
<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-recovery-signed-in>
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
</div>
}
else if (recoveryCode is null && sentence is not null)
{
<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-recovery-sent>
<h1 class="text-xl font-bold">@Localizer["Check your inbox"]</h1>
<p class="text-sm">@sentence</p>
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
</div>
}
else if (recoveryCode is null)
{
<EditForm Model="request" OnValidSubmit="Request" 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-recovery-request>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Forgot your password?"]</h1>
<p class="text-sm opacity-70">@Localizer["Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName"
Immediate="true" disabled="@request.IsUsernameDisabled" autocomplete="username" autocapitalize="none" spellcheck="false" data-recovery-username>
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<span class="text-sm text-center opacity-70">@Localizer["or"]</span>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Email address"]</span>
<DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email"
Immediate="true" disabled="@request.IsEmailDisabled" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email>
<Prefix><i class="ion-md-mail"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Send the recovery link"]</span>
</DButton>
<a class="link text-sm self-center touch-target" href="login">@Localizer["I remember it: sign in"]</a>
</EditForm>
}
else if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
}
else if (isCodeValid is false)
{
<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-recovery-invalid>
<h1 class="text-xl font-bold">@Localizer["This recovery link doesn't work"]</h1>
<p class="text-sm">@Localizer["A link works for one hour, and only once. Ask for a new one."]</p>
<DButton Color="DColor.Primary" OnClick="AskAgain" data-recovery-again>@Localizer["Ask for a new link"]</DButton>
</div>
}
else
{
@* also while the code couldn't be checked (offline, or the check failed): the answer to the new password says *@
<EditForm Model="newPassword" OnValidSubmit="Recover" 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-recovery-new>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Choose a new password"]</h1>
<p class="text-sm opacity-70">@Localizer["Every device signed in to this login is signed out."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["New password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.NewPassword" autocomplete="new-password" data-recovery-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 new password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.RepeatedPassword" autocomplete="new-password" data-recovery-repeat>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-new-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Change the password"]</span>
</DButton>
</EditForm>
}
</div>
</section>
@code {
[Inject] AuthService AuthService { get; set; }
readonly PasswordRecoveryForm request = new();
NewPasswordForm newPassword = new();
/// <summary>The code of the link the page was opened from, kept here once it left the address bar.</summary>
string recoveryCode;
/// <summary>Whether the code works: null until PrivaPub said.</summary>
bool? isCodeValid;
/// <summary>PrivaPub's answer to a request, the same for everyone.</summary>
string sentence;
string error;
bool isWorking;
[SupplyParameterFromQuery(Name = "rc")] public string LinkCode { get; set; }
protected override async Task OnInitializedAsync()
{
if (LinkCode is { Length: > 0 } code)
{
recoveryCode = code.Trim();
newPassword.RecoveryCode = recoveryCode;
// the code is as good as the password for an hour: out of the address bar and the history at once
Navigation.NavigateTo("password-recovery", replace: true);
}
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
if (recoveryCode is null)
return;
var result = await AuthService.CheckRecoveryCode(recoveryCode);
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
isCodeValid = result.Data is true;
}
static string NullIfBlank(string value) => string.IsNullOrWhiteSpace(value) ? null : value;
async Task Request()
{
error = default;
isWorking = true;
request.UserName = request.UserName?.Trim();
request.Email = request.Email?.Trim().ToLowerInvariant();
var result = await AuthService.RequestRecovery(request);
isWorking = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
sentence = result.Data is string { Length: > 0 } said
? said
: Localizer["If that account has an email address, a recovery link is on its way. It works for one hour."];
}
void AskAgain()
{
recoveryCode = null;
isCodeValid = null;
newPassword = new();
error = default;
}
async Task Recover()
{
error = default;
isWorking = true;
var result = await AuthService.Recover(newPassword);
isWorking = false;
if (result.StatusCode is (int)System.Net.HttpStatusCode.NotFound)
{
// used or expired meanwhile
isCodeValid = false;
return;
}
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
Toast.AddResponseSuccess(Localizer["Password changed: sign in with the new one."]);
Navigation.NavigateTo("login");
}
}
+4
View File
@@ -21,6 +21,10 @@
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" /> ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray" <DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" /> ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
@* The theme is the login's: taken at sign-in, saved to it as it changes here (CascadingState.SaveSettingsSoon). *@
<DToggle LabelText="@Localizer["Keep the theme the same on every device signed in to this login"]" Value="CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
ValueChanged="v => Update(s => s.SyncWithLogin = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
data-sync-with-login />
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText" <DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText" ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
data-remind-alt-text /> data-remind-alt-text />
+1 -1
View File
@@ -129,7 +129,7 @@
return; return;
} }
await CascadingState.LoadSession(); await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false; isWorking = false;
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]); Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
Navigation.NavigateTo(""); Navigation.NavigateTo("");
+114
View File
@@ -2285,4 +2285,118 @@
<data name="Invitation link, for someone without a login here" xml:space="preserve"> <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> <value>Link d'invito, per chi non ha un accesso qui</value>
</data> </data>
<data name="Forgot your password?" xml:space="preserve">
<value>Hai dimenticato la password?</value>
</data>
<data name="Check your inbox" xml:space="preserve">
<value>Controlla la posta in arrivo</value>
</data>
<data name="Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it." xml:space="preserve">
<value>Indica il nome utente del tuo accesso o il suo indirizzo email: se l'accesso ha un indirizzo verificato, a quell'indirizzo arriva un link per scegliere una nuova password.</value>
</data>
<data name="or" xml:space="preserve">
<value>oppure</value>
</data>
<data name="Email address" xml:space="preserve">
<value>Indirizzo email</value>
</data>
<data name="Send the recovery link" xml:space="preserve">
<value>Invia il link di recupero</value>
</data>
<data name="I remember it: sign in" xml:space="preserve">
<value>Me la ricordo: accedi</value>
</data>
<data name="This recovery link doesn't work" xml:space="preserve">
<value>Questo link di recupero non funziona</value>
</data>
<data name="A link works for one hour, and only once. Ask for a new one." xml:space="preserve">
<value>Un link funziona per un'ora, e una volta sola. Chiedine uno nuovo.</value>
</data>
<data name="Ask for a new link" xml:space="preserve">
<value>Chiedi un nuovo link</value>
</data>
<data name="Choose a new password" xml:space="preserve">
<value>Scegli una nuova password</value>
</data>
<data name="Every device signed in to this login is signed out." xml:space="preserve">
<value>Ogni dispositivo collegato a questo accesso viene disconnesso.</value>
</data>
<data name="If that account has an email address, a recovery link is on its way. It works for one hour." xml:space="preserve">
<value>Se quell'accesso ha un indirizzo email, un link di recupero è in arrivo. Funziona per un'ora.</value>
</data>
<data name="Password changed: sign in with the new one." xml:space="preserve">
<value>Password cambiata: accedi con quella nuova.</value>
</data>
<data name="verified" xml:space="preserve">
<value>verificato</value>
</data>
<data name="not verified" xml:space="preserve">
<value>non verificato</value>
</data>
<data name="Only a verified address can recover a forgotten password." xml:space="preserve">
<value>Solo un indirizzo verificato permette di recuperare una password dimenticata.</value>
</data>
<data name="No email address yet: without one, a forgotten password can't be recovered." xml:space="preserve">
<value>Ancora nessun indirizzo email: senza, una password dimenticata non si può recuperare.</value>
</data>
<data name="Enter the code sent to {0}." xml:space="preserve">
<value>Inserisci il codice inviato a {0}.</value>
</data>
<data name="Code" xml:space="preserve">
<value>Codice</value>
</data>
<data name="Verify" xml:space="preserve">
<value>Verifica</value>
</data>
<data name="New email address" xml:space="preserve">
<value>Nuovo indirizzo email</value>
</data>
<data name="Send the code" xml:space="preserve">
<value>Invia il codice</value>
</data>
<data name="Remove the address" xml:space="preserve">
<value>Togli l'indirizzo</value>
</data>
<data name="Change" xml:space="preserve">
<value>Cambia</value>
</data>
<data name="Add an address" xml:space="preserve">
<value>Aggiungi un indirizzo</value>
</data>
<data name="Every other device signed in to this login is signed out." xml:space="preserve">
<value>Ogni altro dispositivo collegato a questo accesso viene disconnesso.</value>
</data>
<data name="Sign out everywhere" xml:space="preserve">
<value>Esci ovunque</value>
</data>
<data name="Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours." xml:space="preserve">
<value>Si esce da questo accesso su ogni dispositivo, anche questo, con ogni persona: per un dispositivo perso, o una sessione che non è tua.</value>
</data>
<data name="If that address can be used, a code is on its way to it. It works for one hour." xml:space="preserve">
<value>Se quell'indirizzo si può usare, un codice è in arrivo. Funziona per un'ora.</value>
</data>
<data name="Email address verified." xml:space="preserve">
<value>Indirizzo email verificato.</value>
</data>
<data name="Remove the email address?" xml:space="preserve">
<value>Togliere l'indirizzo email?</value>
</data>
<data name="Without one, a forgotten password can't be recovered." xml:space="preserve">
<value>Senza, una password dimenticata non si può recuperare.</value>
</data>
<data name="Email address removed." xml:space="preserve">
<value>Indirizzo email tolto.</value>
</data>
<data name="Sign out everywhere?" xml:space="preserve">
<value>Uscire ovunque?</value>
</data>
<data name="Every device and every persona of this login signs out, this one too. Signing in again takes the password." xml:space="preserve">
<value>Ogni dispositivo e ogni persona di questo accesso escono, anche questo. Per rientrare serve la password.</value>
</data>
<data name="Wrong password." xml:space="preserve">
<value>Password errata.</value>
</data>
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
<value>Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso</value>
</data>
</root> </root>
+114
View File
@@ -2285,4 +2285,118 @@
<data name="Invitation link, for someone without a login here" xml:space="preserve"> <data name="Invitation link, for someone without a login here" xml:space="preserve">
<value>Invitation link, for someone without a login here</value> <value>Invitation link, for someone without a login here</value>
</data> </data>
<data name="Forgot your password?" xml:space="preserve">
<value>Forgot your password?</value>
</data>
<data name="Check your inbox" xml:space="preserve">
<value>Check your inbox</value>
</data>
<data name="Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it." xml:space="preserve">
<value>Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it.</value>
</data>
<data name="or" xml:space="preserve">
<value>or</value>
</data>
<data name="Email address" xml:space="preserve">
<value>Email address</value>
</data>
<data name="Send the recovery link" xml:space="preserve">
<value>Send the recovery link</value>
</data>
<data name="I remember it: sign in" xml:space="preserve">
<value>I remember it: sign in</value>
</data>
<data name="This recovery link doesn't work" xml:space="preserve">
<value>This recovery link doesn't work</value>
</data>
<data name="A link works for one hour, and only once. Ask for a new one." xml:space="preserve">
<value>A link works for one hour, and only once. Ask for a new one.</value>
</data>
<data name="Ask for a new link" xml:space="preserve">
<value>Ask for a new link</value>
</data>
<data name="Choose a new password" xml:space="preserve">
<value>Choose a new password</value>
</data>
<data name="Every device signed in to this login is signed out." xml:space="preserve">
<value>Every device signed in to this login is signed out.</value>
</data>
<data name="If that account has an email address, a recovery link is on its way. It works for one hour." xml:space="preserve">
<value>If that account has an email address, a recovery link is on its way. It works for one hour.</value>
</data>
<data name="Password changed: sign in with the new one." xml:space="preserve">
<value>Password changed: sign in with the new one.</value>
</data>
<data name="verified" xml:space="preserve">
<value>verified</value>
</data>
<data name="not verified" xml:space="preserve">
<value>not verified</value>
</data>
<data name="Only a verified address can recover a forgotten password." xml:space="preserve">
<value>Only a verified address can recover a forgotten password.</value>
</data>
<data name="No email address yet: without one, a forgotten password can't be recovered." xml:space="preserve">
<value>No email address yet: without one, a forgotten password can't be recovered.</value>
</data>
<data name="Enter the code sent to {0}." xml:space="preserve">
<value>Enter the code sent to {0}.</value>
</data>
<data name="Code" xml:space="preserve">
<value>Code</value>
</data>
<data name="Verify" xml:space="preserve">
<value>Verify</value>
</data>
<data name="New email address" xml:space="preserve">
<value>New email address</value>
</data>
<data name="Send the code" xml:space="preserve">
<value>Send the code</value>
</data>
<data name="Remove the address" xml:space="preserve">
<value>Remove the address</value>
</data>
<data name="Change" xml:space="preserve">
<value>Change</value>
</data>
<data name="Add an address" xml:space="preserve">
<value>Add an address</value>
</data>
<data name="Every other device signed in to this login is signed out." xml:space="preserve">
<value>Every other device signed in to this login is signed out.</value>
</data>
<data name="Sign out everywhere" xml:space="preserve">
<value>Sign out everywhere</value>
</data>
<data name="Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours." xml:space="preserve">
<value>Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours.</value>
</data>
<data name="If that address can be used, a code is on its way to it. It works for one hour." xml:space="preserve">
<value>If that address can be used, a code is on its way to it. It works for one hour.</value>
</data>
<data name="Email address verified." xml:space="preserve">
<value>Email address verified.</value>
</data>
<data name="Remove the email address?" xml:space="preserve">
<value>Remove the email address?</value>
</data>
<data name="Without one, a forgotten password can't be recovered." xml:space="preserve">
<value>Without one, a forgotten password can't be recovered.</value>
</data>
<data name="Email address removed." xml:space="preserve">
<value>Email address removed.</value>
</data>
<data name="Sign out everywhere?" xml:space="preserve">
<value>Sign out everywhere?</value>
</data>
<data name="Every device and every persona of this login signs out, this one too. Signing in again takes the password." xml:space="preserve">
<value>Every device and every persona of this login signs out, this one too. Signing in again takes the password.</value>
</data>
<data name="Wrong password." xml:space="preserve">
<value>Wrong password.</value>
</data>
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
<value>Keep the theme the same on every device signed in to this login</value>
</data>
</root> </root>
+170 -1
View File
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Text.Json;
using decePubClient.Extensions; using decePubClient.Extensions;
using decePubClient.Helpers; using decePubClient.Helpers;
@@ -145,13 +146,155 @@ namespace decePubClient.Services
await authStateProvider.SetSession(jwtUser, cancellationToken); await authStateProvider.SetSession(jwtUser, cancellationToken);
} }
/// <summary>Changes the root's password, the current one given.</summary> /// <summary>
/// Changes the root's password, the current one given. PrivaPub ends every session of the login with it, and revokes
/// every persona's token, this device's too; it answers with a new JWT, so the session goes on with it and the current
/// persona gets a new token at once (the others when they are next used).
/// </summary>
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default) public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken); using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
// a server from before sessions answered with nothing, and ended none
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
if (string.IsNullOrEmpty(jwtUser?.Token))
return new WebResult();
await authStateProvider.SetSession(jwtUser, cancellationToken);
await authStateProvider.ForgetPersonaTokens(cancellationToken);
var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.PersonaId is null)
return new WebResult { Data = jwtUser };
using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken);
if (!exchanged.IsSuccessStatusCode)
await LogFailure(exchanged, nameof(ChangePassword), cancellationToken);
return new WebResult { Data = jwtUser };
}
/// <summary>
/// Asks PrivaPub to mail a recovery link to the login named, or to the login whose verified address is given. Data is
/// PrivaPub's answer, the same sentence whether or not there is such a login. Sent with no credentials.
/// </summary>
public async Task<WebResult> RequestRecovery(PasswordRecoveryForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverPassword, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await ReadSentence(response, cancellationToken) };
}
/// <summary>Whether a recovery link's code still works (Data, a bool): it lasts an hour and is used once.</summary>
public async Task<WebResult> CheckRecoveryCode(string recoveryCode, CancellationToken cancellationToken = default)
{
// [FromBody] string: the code goes as a JSON string
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverValid, recoveryCode, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<bool>(cancellationToken) };
}
/// <summary>Sets a new password through a recovery link's code; PrivaPub ends every session the login had.</summary>
public async Task<WebResult> Recover(NewPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverUpdatePassword, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>The login itself (its name, its email address and whether it is verified, one waiting for its code), or null.</summary>
public async Task<ViewAccount> Account(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Account), cancellationToken);
return null;
}
return await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken);
}
/// <summary>
/// Asks for the login's address to become another, the password given: PrivaPub mails a code to it, which VerifyEmail
/// brings back. Data is PrivaPub's answer, the same sentence whether or not the address is free.
/// </summary>
public async Task<WebResult> ChangeEmail(EmailChangeForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_Email, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await ReadPasswordFailure(response, cancellationToken);
return new WebResult { Data = await ReadSentence(response, cancellationToken) };
}
/// <summary>Makes the address waiting for its code the login's, verified; Data is the login as it is now.</summary>
public async Task<WebResult> VerifyEmail(string code, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_EmailVerify, new EmailCodeForm { Code = code }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
}
/// <summary>Removes the login's address, the password given; without one a forgotten password can't be recovered.
/// Data is the login as it is now.</summary>
public async Task<WebResult> RemoveEmail(string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_EmailRemove, new PasswordForm { Password = password },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await ReadPasswordFailure(response, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
}
/// <summary>
/// Ends every session of the login, this device's too, and PrivaPub revokes every persona's token with them. The client
/// forgets its session at once; the caller signs out, which then has nothing left to tell PrivaPub.
/// </summary>
public async Task<WebResult> LogoutEverywhere(CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_LogoutEverywhere, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await authStateProvider.LogoutAsync(cancellationToken);
return new WebResult();
}
/// <summary>The theme and language the login keeps for every device, or null; SavedAt is null until a client saved them.</summary>
public async Task<ViewUserSettings> Settings(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_UserSettings, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Settings), cancellationToken);
return null;
}
return await response.DefaultReadFromJsonAsync<ViewUserSettings>(cancellationToken);
}
/// <summary>Saves the theme and language as the login's, for every device signed in to it. A failure is logged here.</summary>
public async Task<WebResult> SaveSettings(ViewUserSettings settings, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_UpdateUserSettings, settings, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(SaveSettings)}", cancellationToken);
return result;
}
return new WebResult(); return new WebResult();
} }
@@ -191,6 +334,32 @@ namespace decePubClient.Services
await authStateProvider.LogoutAsync(cancellationToken); await authStateProvider.LogoutAsync(cancellationToken);
} }
/// <summary>
/// The failure of what only the password may do: PrivaPub refuses a wrong one with 403, which ReadWebResult would
/// take for a missing permission.
/// </summary>
async Task<WebResult> ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
}
/// <summary>A sentence PrivaPub answered with: a JSON string, or plain text (ASP.NET writes a string so unless JSON is asked for).</summary>
static async Task<string> ReadSentence(HttpResponseMessage response, CancellationToken cancellationToken)
{
try
{
var text = await response.Content.ReadAsStringAsync(cancellationToken);
return text.StartsWith('"') ? JsonSerializer.Deserialize<string>(text, SUtility.DefaultSerializer) : text;
}
catch (Exception ex) when (ex is OperationCanceledException or JsonException)
{
return null;
}
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{ {
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+10
View File
@@ -89,6 +89,16 @@ namespace decePubClient.Services
await Save(session, cancellationToken); await Save(session, cancellationToken);
} }
/// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary>
public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (session.PersonaTokens.Count == 0)
return;
session.PersonaTokens.Clear();
await Save(session, cancellationToken);
}
public async Task LogoutAsync(CancellationToken cancellationToken = default) public async Task LogoutAsync(CancellationToken cancellationToken = default)
{ {
logger.LogInformation($"set null({nameof(LogoutAsync)})"); logger.LogInformation($"set null({nameof(LogoutAsync)})");