diff --git a/Foxnouns.Backend/Controllers/DeleteUserController.cs b/Foxnouns.Backend/Controllers/DeleteUserController.cs new file mode 100644 index 0000000..b611c35 --- /dev/null +++ b/Foxnouns.Backend/Controllers/DeleteUserController.cs @@ -0,0 +1,75 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Middleware; +using Microsoft.AspNetCore.Mvc; +using NodaTime; + +namespace Foxnouns.Backend.Controllers; + +[Route("/api/internal/self-delete")] +[Authorize("*")] +[ApiExplorerSettings(IgnoreApi = true)] +public class DeleteUserController(DatabaseContext db, IClock clock, ILogger logger) + : ApiControllerBase +{ + private readonly ILogger _logger = logger.ForContext(); + + [HttpPost("delete")] + public async Task DeleteSelfAsync() + { + _logger.Information( + "User {UserId} has requested their account to be deleted", + CurrentUser!.Id + ); + + CurrentUser.Deleted = true; + CurrentUser.DeletedAt = clock.GetCurrentInstant(); + + db.Update(CurrentUser); + await db.SaveChangesAsync(); + return NoContent(); + } + + [HttpPost("force")] + [Limit(UsableByDeletedUsers = true)] + public async Task ForceDeleteAsync() + { + if (!CurrentUser!.Deleted) + throw new ApiError.BadRequest("Your account isn't deleted."); + + _logger.Information( + "User {UserId} has requested an early full delete of their account", + CurrentUser.Id + ); + + // This is the easiest way to force delete a user, don't judge me + CurrentUser.DeletedAt = clock.GetCurrentInstant() - Duration.FromDays(365); + db.Update(CurrentUser); + await db.SaveChangesAsync(); + return NoContent(); + } + + [HttpPost("undelete")] + [Limit(UsableByDeletedUsers = true)] + public async Task UndeleteSelfAsync() + { + if (!CurrentUser!.Deleted) + throw new ApiError.BadRequest("Your account isn't deleted."); + if (CurrentUser!.DeletedBy != null) + { + throw new ApiError.BadRequest( + "Your account has been suspended and can't be reactivated by yourself." + ); + } + + _logger.Information( + "User {UserId} has requested to undelete their account", + CurrentUser.Id + ); + + CurrentUser.Deleted = false; + CurrentUser.DeletedAt = null; + db.Update(CurrentUser); + await db.SaveChangesAsync(); + return NoContent(); + } +} diff --git a/Foxnouns.Backend/Controllers/ExportsController.cs b/Foxnouns.Backend/Controllers/ExportsController.cs index 315efbc..9d23e41 100644 --- a/Foxnouns.Backend/Controllers/ExportsController.cs +++ b/Foxnouns.Backend/Controllers/ExportsController.cs @@ -26,6 +26,7 @@ namespace Foxnouns.Backend.Controllers; [Route("/api/internal/data-exports")] [Authorize("identify")] +[Limit(UsableByDeletedUsers = true)] [ApiExplorerSettings(IgnoreApi = true)] public class ExportsController( ILogger logger, diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index 2b145ac..e4f4c77 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -34,7 +34,7 @@ public class FlagsController( ) : ApiControllerBase { [HttpGet] - [Limit(UsableBySuspendedUsers = true)] + [Limit(UsableByDeletedUsers = true)] [Authorize("user.read_flags")] [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] public async Task GetFlagsAsync(CancellationToken ct = default) diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 9b94b30..0d145d8 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -44,7 +44,7 @@ public class MembersController( [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] - [Limit(UsableBySuspendedUsers = true)] + [Limit(UsableByDeletedUsers = true)] public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); @@ -53,7 +53,7 @@ public class MembersController( [HttpGet("{memberRef}")] [ProducesResponseType(StatusCodes.Status200OK)] - [Limit(UsableBySuspendedUsers = true)] + [Limit(UsableByDeletedUsers = true)] public async Task GetMemberAsync( string userRef, string memberRef, diff --git a/Foxnouns.Backend/Controllers/NotificationsController.cs b/Foxnouns.Backend/Controllers/NotificationsController.cs index 8bea907..f258b3c 100644 --- a/Foxnouns.Backend/Controllers/NotificationsController.cs +++ b/Foxnouns.Backend/Controllers/NotificationsController.cs @@ -17,7 +17,7 @@ public class NotificationsController( { [HttpGet] [Authorize("user.moderation")] - [Limit(UsableBySuspendedUsers = true)] + [Limit(UsableByDeletedUsers = true)] public async Task GetNotificationsAsync([FromQuery] bool all = false) { IQueryable query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id); @@ -31,7 +31,7 @@ public class NotificationsController( [HttpPut("{id}/ack")] [Authorize("user.moderation")] - [Limit(UsableBySuspendedUsers = true)] + [Limit(UsableByDeletedUsers = true)] public async Task AcknowledgeNotificationAsync(Snowflake id) { Notification? notification = await db.Notifications.FirstOrDefaultAsync(n => diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index d567bdb..e909ef8 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -42,7 +42,7 @@ public class UsersController( [HttpGet("{userRef}")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - [Limit(UsableBySuspendedUsers = true)] + [Limit(UsableByDeletedUsers = true)] public async Task GetUserAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); diff --git a/Foxnouns.Backend/Middleware/LimitMiddleware.cs b/Foxnouns.Backend/Middleware/LimitMiddleware.cs index 1c5f522..6092041 100644 --- a/Foxnouns.Backend/Middleware/LimitMiddleware.cs +++ b/Foxnouns.Backend/Middleware/LimitMiddleware.cs @@ -41,7 +41,7 @@ public class LimitMiddleware : IMiddleware return; } - if (token?.User.Deleted == true && !attribute.UsableBySuspendedUsers) + if (token?.User.Deleted == true && !attribute.UsableByDeletedUsers) throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin) @@ -62,7 +62,7 @@ public class LimitMiddleware : IMiddleware [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class LimitAttribute : Attribute { - public bool UsableBySuspendedUsers { get; init; } + public bool UsableByDeletedUsers { get; init; } public bool RequireAdmin { get; init; } public bool RequireModerator { get; init; } } diff --git a/Foxnouns.Frontend/src/lib/components/Navbar.svelte b/Foxnouns.Frontend/src/lib/components/Navbar.svelte index 4330312..967d688 100644 --- a/Foxnouns.Frontend/src/lib/components/Navbar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Navbar.svelte @@ -25,12 +25,14 @@ {#if user.suspended} {$t("nav.suspended-account-hint")}
- {$t("nav.appeal-suspension-link")} + {$t("nav.delete-permanently-link")} • + {$t("nav.appeal-suspension-link")} • + {$t("nav.export-link")} {:else} {$t("nav.deleted-account-hint")}
- {$t("nav.reactivate-account-link")} • - {$t("nav.delete-permanently-link")} + {$t("nav.reactivate-or-delete-link")} • + {$t("nav.export-link")} {/if} {/if} diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 16f5527..87a9cdd 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -5,9 +5,11 @@ "settings": "Settings", "suspended-account-hint": "Your account has been suspended. Your profile has been hidden and you will not be able to change any settings.", "appeal-suspension-link": "I want to appeal", - "deleted-account-hint": "You have requested deletion of your account. If you want to reactivate it, click the link below.", + "deleted-account-hint": "You have requested deletion of your account.", "reactivate-account-link": "Reactivate account", - "delete-permanently-link": "I want my account deleted permanently" + "delete-permanently-link": "I want my account deleted permanently", + "reactivate-or-delete-link": "I want to reactivate my account or delete all my data", + "export-link": "I want to export a copy of my data" }, "avatar-tooltip": "Avatar for {{name}}", "profile": { @@ -155,7 +157,17 @@ "flag-description-placeholder": "Description", "flag-name-placeholder": "Name", "flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved.", - "custom-preferences-title": "Custom preferences" + "custom-preferences-title": "Custom preferences", + "change-username-header": "Change your username", + "force-delete-button": "Delete my account permanently", + "force-delete-warning": "This is irreversible. Consider exporting a copy of your data before doing this.", + "force-delete-explanation": "Your account is currently pending deletion. If you want your data deleted permanently, use the button below.", + "reactivate-explanation": "Your account is currently pending deletion. If you want to cancel this and keep using your account, use the link below.", + "reactivate-header": "Reactivate your account", + "force-delete-header": "Permanently delete your account", + "reactivate-button": "Reactivate my account", + "reactivated-header": "Account reactivated", + "reactivated-explanation": "Your account has been reactivated!" }, "yes": "Yes", "no": "No", diff --git a/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte b/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte index 24458ab..a4ce0ac 100644 --- a/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte +++ b/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte @@ -16,8 +16,6 @@ data.member ? `/@${data.user.username}/${data.member.name}` : `/@${data.user.username}`, ); - console.log(data.user, !!data.member); - let reasons = $derived.by(() => { const reasons = []; for (const value of Object.values(ReportReason)) { diff --git a/Foxnouns.Frontend/src/routes/settings/+page.svelte b/Foxnouns.Frontend/src/routes/settings/+page.svelte index 74b4a49..6963b31 100644 --- a/Foxnouns.Frontend/src/routes/settings/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/+page.svelte @@ -18,9 +18,37 @@

{$t("settings.general-information-tab")}

+{#if data.user.deleted} +
+ {#if !data.user.suspended} +
+

{$t("settings.reactivate-header")}

+

+ {$t("settings.reactivate-explanation")} +

+ + {$t("settings.reactivate-button")} + +
+ {/if} +
+

{$t("settings.force-delete-header")}

+

+ {$t("settings.force-delete-explanation")} + + {$t("settings.force-delete-warning")} + +

+ + {$t("settings.force-delete-button")} + +
+
+{/if} +
-
Change your username
+
{$t("settings.change-username-header")}
@@ -80,6 +108,13 @@ {$t("settings.force-log-out-button")}
+{#if !data.user.deleted} +
+

Delete your account

+

+
+{/if} +

{$t("settings.table-title")}

diff --git a/Foxnouns.Frontend/src/routes/settings/reactivate/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/reactivate/+page.server.ts new file mode 100644 index 0000000..0ac29ae --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/reactivate/+page.server.ts @@ -0,0 +1,23 @@ +import { fastRequest } from "$api"; +import ApiError, { ErrorCode } from "$api/error"; +import { redirect } from "@sveltejs/kit"; + +export const load = async ({ parent, fetch, cookies }) => { + const { meUser } = await parent(); + if (!meUser) redirect(303, "/"); + + if (meUser.suspended || !meUser.deleted) + throw new ApiError({ + message: "You cannot use this page.", + status: 403, + code: ErrorCode.Forbidden, + }); + + await fastRequest("POST", "/self-delete/undelete", { + fetch, + cookies, + isInternal: true, + }); + + return { user: meUser! }; +}; diff --git a/Foxnouns.Frontend/src/routes/settings/reactivate/+page@.svelte b/Foxnouns.Frontend/src/routes/settings/reactivate/+page@.svelte new file mode 100644 index 0000000..acfb617 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/reactivate/+page@.svelte @@ -0,0 +1,20 @@ + + +
+

{$t("settings.reactivated-header")}

+ +

{$t("settings.reactivated-explanation")}

+ + +