Account deletion

This commit is contained in:
LittleSheep 2025-05-24 23:29:36 +08:00
parent 80b7812a87
commit 59bc9edd4b
13 changed files with 191 additions and 20 deletions

View File

@ -219,6 +219,24 @@ public class AccountController(
return profile;
}
[HttpDelete("me")]
[Authorize]
public async Task<ActionResult> RequestDeleteAccount()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.RequestAccountDeletion(currentUser);
}
catch (InvalidOperationException)
{
return BadRequest("You already requested account deletion within 24 hours.");
}
return Ok();
}
public class StatusRequest
{
public StatusAttitude Attitude { get; set; }

View File

@ -8,17 +8,18 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging.Abstractions;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class AccountService(
AppDatabase db,
ICacheService cache,
IStringLocalizerFactory factory
MagicSpellService spells,
ICacheService cache
)
{
public const string AccountCachePrefix = "Account_";
public async Task PurgeAccountCache(Account account)
{
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
@ -46,6 +47,18 @@ public class AccountService(
return profile?.Level;
}
public async Task RequestAccountDeletion(Account account)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountRemoval,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
/// Maintenance methods for server administrator
public async Task EnsureAccountProfileCreated()
{

View File

@ -1,7 +0,0 @@
namespace DysonNetwork.Sphere.Account.Email;
public class LandingEmailModel
{
public required string Name { get; set; }
public required string VerificationLink { get; set; }
}

View File

@ -1,6 +1,6 @@
using System.Globalization;
using System.Security.Cryptography;
using DysonNetwork.Sphere.Account.Email;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Resources.Localization;
@ -24,9 +24,25 @@ public class MagicSpellService(
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null
Instant? affectedAt = null,
bool preventRepeat = false
)
{
if (preventRepeat)
{
var now = SystemClock.Instance.GetCurrentInstant();
var existingSpell = await db.MagicSpells
.Where(s => s.AccountId == account.Id)
.Where(s => s.Type == type)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
if (existingSpell != null)
{
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
}
}
var spellWord = _GenerateRandomString(128);
var spell = new MagicSpell
{
@ -79,7 +95,19 @@ public class MagicSpellService(
new LandingEmailModel
{
Name = contact.Account.Name,
VerificationLink = link
Link = link
}
);
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Name,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;

View File

@ -0,0 +1,13 @@
namespace DysonNetwork.Sphere.Email;
public class LandingEmailModel
{
public required string Name { get; set; }
public required string Link { get; set; }
}
public class AccountDeletionEmailModel
{
public required string Name { get; set; }
public required string Link { get; set; }
}

View File

@ -2,7 +2,7 @@ using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Components;
using MimeKit;
namespace DysonNetwork.Sphere.Account.Email;
namespace DysonNetwork.Sphere.Email;
public class EmailServiceConfiguration
{

View File

@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using RouteData = Microsoft.AspNetCore.Routing.RouteData;
namespace DysonNetwork.Sphere.Account.Email;
namespace DysonNetwork.Sphere.Email;
public class RazorViewRenderer(
IServiceProvider serviceProvider,

View File

@ -2,5 +2,4 @@ namespace DysonNetwork.Sphere.Localization;
public class EmailResource
{
}

View File

@ -0,0 +1,65 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
<EmailLayout>
<table class="container">
<tr>
<td class="columns">
<h1 style="font-size: 1.875rem; font-weight: 700; color: #111827; margin: 0; text-align: center;">
@(Localizer["AccountDeletionHeader"])
</h1>
</td>
</tr>
<tr>
<td class="columns">
<p style="color: #374151; margin: 0;">
@(Localizer["AccountDeletionPara1"]) @@@Name,
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["AccountDeletionPara2"])
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["AccountDeletionPara3"])
</p>
</td>
</tr>
<tr>
<td class="columns">
<div style="text-align: center;">
<a href="@Link" target="_blank"
style="background-color: #2563eb; color: #ffffff; padding: 0.75rem 1.5rem; border-radius: 0.5rem; font-weight: 600; text-decoration: none;">
@(Localizer["AccountDeletionButton"])
</a>
</div>
</td>
</tr>
<tr>
<td class="columns">
<p style="color: #374151; margin: 0;">
@(LocalizerShared["EmailLinkHint"])
<br>
<a href="@Link" style="color: #2563eb; word-break: break-all;">@Link</a>
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["AccountDeletionPara4"])
</p>
<p style="color: #374151; margin: 2rem 0 0 0;">
@(LocalizerShared["EmailFooter1"]) <br />
@(LocalizerShared["EmailFooter2"])
</p>
</td>
</tr>
</table>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@ -29,7 +29,7 @@
<tr>
<td class="columns">
<div style="text-align: center;">
<a href="@VerificationLink" target="_blank"
<a href="@Link" target="_blank"
style="background-color: #2563eb; color: #ffffff; padding: 0.75rem 1.5rem; border-radius: 0.5rem; font-weight: 600; text-decoration: none;">
@(Localizer["LandingButton1"])
</a>
@ -42,7 +42,7 @@
<p style="color: #374151; margin: 0;">
@(LocalizerShared["EmailLinkHint"])
<br>
<a href="@VerificationLink" style="color: #2563eb; word-break: break-all;">@VerificationLink</a>
<a href="@Link" style="color: #2563eb; word-break: break-all;">@Link</a>
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["LandingPara4"])
@ -58,7 +58,7 @@
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string VerificationLink { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;

View File

@ -5,7 +5,7 @@ using System.Text.Json;
using System.Threading.RateLimiting;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Account.Email;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat;

View File

@ -39,4 +39,25 @@
<data name="EmailLandingTitle" xml:space="preserve">
<value>Confirm your registration</value>
</data>
<data name="AccountDeletionHeader" xml:space="preserve">
<value>Account Deletion Confirmation</value>
</data>
<data name="AccountDeletionPara1" xml:space="preserve">
<value>Dear, </value>
</data>
<data name="AccountDeletionPara2" xml:space="preserve">
<value>We've received a request to delete your Solar Network account. We're sorry to see you go.</value>
</data>
<data name="AccountDeletionPara3" xml:space="preserve">
<value>To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone.</value>
</data>
<data name="AccountDeletionButton" xml:space="preserve">
<value>Confirm Account Deletion</value>
</data>
<data name="AccountDeletionPara4" xml:space="preserve">
<value>If you did not request to delete your account, please ignore this email or contact our support team immediately.</value>
</data>
<data name="EmailAccountDeletionTitle" xml:space="preserve">
<value>Confirm your account deletion</value>
</data>
</root>

View File

@ -32,4 +32,25 @@
<data name="EmailLandingTitle" xml:space="preserve">
<value>确认你的注册</value>
</data>
<data name="AccountDeletionHeader" xml:space="preserve">
<value>账户删除确认</value>
</data>
<data name="AccountDeletionPara1" xml:space="preserve">
<value>尊敬的 </value>
</data>
<data name="AccountDeletionPara2" xml:space="preserve">
<value>我们收到了删除您 Solar Network 账户的请求。我们很遗憾看到您的离开。</value>
</data>
<data name="AccountDeletionPara3" xml:space="preserve">
<value>请点击下方按钮确认删除您的账户。请注意,此操作是永久性的,无法撤销。</value>
</data>
<data name="AccountDeletionButton" xml:space="preserve">
<value>确认删除账户</value>
</data>
<data name="AccountDeletionPara4" xml:space="preserve">
<value>如果您并未请求删除账户,请忽略此邮件或立即联系我们的支持团队。</value>
</data>
<data name="EmailAccountDeletionTitle" xml:space="preserve">
<value>确认删除您的账户</value>
</data>
</root>