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:
1 parent
fd2f2e7c65
commit
7d4b35f0d7
15 files changed
+1056
-17
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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>))]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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("");
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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)})");
|
||||||
|
|||||||
Reference in new issue
Block a user