Build / Build (push) Successful in 56s
Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
321 lines
12 KiB
Plaintext
321 lines
12 KiB
Plaintext
@inherits LocalizableComponentBase
|
|
|
|
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
|
|
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); its two-factor sign-in (TotpEditor); 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-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>
|
|
<DataAnnotationsValidator />
|
|
<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"
|
|
placeholder="@Localizer["Current password"]" data-account-current />
|
|
<div class="flex flex-wrap gap-2">
|
|
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password"
|
|
placeholder="@Localizer["New password"]" data-account-new />
|
|
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.RepeatedPassword" Immediate="true" autocomplete="new-password"
|
|
placeholder="@Localizer["Repeat the new password"]" data-account-repeat />
|
|
</div>
|
|
<ErrorViewer />
|
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-password-save>
|
|
<i class="ion-md-key"></i><span>@Localizer["Change the password"]</span>
|
|
</DButton>
|
|
</EditForm>
|
|
|
|
<TotpEditor />
|
|
|
|
<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>
|
|
<DataAnnotationsValidator />
|
|
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
|
|
<p class="text-sm">@Localizer["Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone."]</p>
|
|
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true" autocomplete="current-password"
|
|
placeholder="@Localizer["Current password"]" data-account-delete-password />
|
|
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
|
|
<ErrorViewer />
|
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || !understood)" data-account-delete-confirm>
|
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
|
</DButton>
|
|
</EditForm>
|
|
</div>
|
|
|
|
@code {
|
|
[Inject] AuthService AuthService { get; set; }
|
|
[Inject] ConfirmService Confirm { get; set; }
|
|
[Inject] PushService Push { 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();
|
|
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 isSaving;
|
|
|
|
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()
|
|
{
|
|
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);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
password = new();
|
|
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;
|
|
}
|
|
|
|
// PrivaPub ended every Web Push subscription with the tokens: this browser forgets its own
|
|
await Push.Forget();
|
|
await CascadingState.SignOut();
|
|
Navigation.NavigateTo("login");
|
|
}
|
|
|
|
async Task Delete()
|
|
{
|
|
isSaving = true;
|
|
var result = await AuthService.DeleteAccount(removal);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
// the personas' Web Push subscriptions went with them: this browser forgets its own, asking no persona gone
|
|
await Push.Forget();
|
|
await CascadingState.SignOut();
|
|
Navigation.NavigateTo("login");
|
|
}
|
|
}
|