feat: self-service deletion API, reactivate account page
This commit is contained in:
parent
8a2ffd7d69
commit
96725cc304
13 changed files with 183 additions and 17 deletions
75
Foxnouns.Backend/Controllers/DeleteUserController.cs
Normal file
75
Foxnouns.Backend/Controllers/DeleteUserController.cs
Normal file
|
@ -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<DeleteUserController>();
|
||||||
|
|
||||||
|
[HttpPost("delete")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[Route("/api/internal/data-exports")]
|
[Route("/api/internal/data-exports")]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public class ExportsController(
|
public class ExportsController(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
|
|
@ -34,7 +34,7 @@ public class FlagsController(
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
[Authorize("user.read_flags")]
|
[Authorize("user.read_flags")]
|
||||||
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||||
|
|
|
@ -44,7 +44,7 @@ public class MembersController(
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -53,7 +53,7 @@ public class MembersController(
|
||||||
|
|
||||||
[HttpGet("{memberRef}")]
|
[HttpGet("{memberRef}")]
|
||||||
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetMemberAsync(
|
public async Task<IActionResult> GetMemberAsync(
|
||||||
string userRef,
|
string userRef,
|
||||||
string memberRef,
|
string memberRef,
|
||||||
|
|
|
@ -17,7 +17,7 @@ public class NotificationsController(
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize("user.moderation")]
|
[Authorize("user.moderation")]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
|
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
|
||||||
{
|
{
|
||||||
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
|
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
|
||||||
|
@ -31,7 +31,7 @@ public class NotificationsController(
|
||||||
|
|
||||||
[HttpPut("{id}/ack")]
|
[HttpPut("{id}/ack")]
|
||||||
[Authorize("user.moderation")]
|
[Authorize("user.moderation")]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
|
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
|
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
|
||||||
|
|
|
@ -42,7 +42,7 @@ public class UsersController(
|
||||||
|
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Limit(UsableByDeletedUsers = true)]
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
|
|
@ -41,7 +41,7 @@ public class LimitMiddleware : IMiddleware
|
||||||
return;
|
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.");
|
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
|
||||||
|
|
||||||
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
|
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
|
||||||
|
@ -62,7 +62,7 @@ public class LimitMiddleware : IMiddleware
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
public class LimitAttribute : Attribute
|
public class LimitAttribute : Attribute
|
||||||
{
|
{
|
||||||
public bool UsableBySuspendedUsers { get; init; }
|
public bool UsableByDeletedUsers { get; init; }
|
||||||
public bool RequireAdmin { get; init; }
|
public bool RequireAdmin { get; init; }
|
||||||
public bool RequireModerator { get; init; }
|
public bool RequireModerator { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,12 +25,14 @@
|
||||||
{#if user.suspended}
|
{#if user.suspended}
|
||||||
<strong>{$t("nav.suspended-account-hint")}</strong>
|
<strong>{$t("nav.suspended-account-hint")}</strong>
|
||||||
<br />
|
<br />
|
||||||
<a href="/contact">{$t("nav.appeal-suspension-link")}</a>
|
<a href="/settings">{$t("nav.delete-permanently-link")}</a> •
|
||||||
|
<a href="/contact">{$t("nav.appeal-suspension-link")}</a> •
|
||||||
|
<a href="/settings/export">{$t("nav.export-link")}</a>
|
||||||
{:else}
|
{:else}
|
||||||
<strong>{$t("nav.deleted-account-hint")}</strong>
|
<strong>{$t("nav.deleted-account-hint")}</strong>
|
||||||
<br />
|
<br />
|
||||||
<a href="/settings/reactivate">{$t("nav.reactivate-account-link")}</a> •
|
<a href="/settings">{$t("nav.reactivate-or-delete-link")}</a> •
|
||||||
<a href="/contact">{$t("nav.delete-permanently-link")}</a>
|
<a href="/settings/export">{$t("nav.export-link")}</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -5,9 +5,11 @@
|
||||||
"settings": "Settings",
|
"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.",
|
"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",
|
"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",
|
"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}}",
|
"avatar-tooltip": "Avatar for {{name}}",
|
||||||
"profile": {
|
"profile": {
|
||||||
|
@ -155,7 +157,17 @@
|
||||||
"flag-description-placeholder": "Description",
|
"flag-description-placeholder": "Description",
|
||||||
"flag-name-placeholder": "Name",
|
"flag-name-placeholder": "Name",
|
||||||
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved.",
|
"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",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
|
|
@ -16,8 +16,6 @@
|
||||||
data.member ? `/@${data.user.username}/${data.member.name}` : `/@${data.user.username}`,
|
data.member ? `/@${data.user.username}/${data.member.name}` : `/@${data.user.username}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(data.user, !!data.member);
|
|
||||||
|
|
||||||
let reasons = $derived.by(() => {
|
let reasons = $derived.by(() => {
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
for (const value of Object.values(ReportReason)) {
|
for (const value of Object.values(ReportReason)) {
|
||||||
|
|
|
@ -18,9 +18,37 @@
|
||||||
|
|
||||||
<h3>{$t("settings.general-information-tab")}</h3>
|
<h3>{$t("settings.general-information-tab")}</h3>
|
||||||
|
|
||||||
|
{#if data.user.deleted}
|
||||||
|
<div class="row mb-3">
|
||||||
|
{#if !data.user.suspended}
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("settings.reactivate-header")}</h4>
|
||||||
|
<p>
|
||||||
|
{$t("settings.reactivate-explanation")}
|
||||||
|
</p>
|
||||||
|
<a href="/settings/reactivate" class="btn btn-success">
|
||||||
|
{$t("settings.reactivate-button")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("settings.force-delete-header")}</h4>
|
||||||
|
<p>
|
||||||
|
{$t("settings.force-delete-explanation")}
|
||||||
|
<strong>
|
||||||
|
{$t("settings.force-delete-warning")}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<a href="/settings/force-delete" class="btn btn-danger">
|
||||||
|
{$t("settings.force-delete-button")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
<h5>Change your username</h5>
|
<h5>{$t("settings.change-username-header")}</h5>
|
||||||
<form method="POST" action="?/changeUsername" use:enhance>
|
<form method="POST" action="?/changeUsername" use:enhance>
|
||||||
<FormGroup class="mb-3">
|
<FormGroup class="mb-3">
|
||||||
<InputGroup class="m-1 mt-3 w-md-75">
|
<InputGroup class="m-1 mt-3 w-md-75">
|
||||||
|
@ -80,6 +108,13 @@
|
||||||
<a class="btn btn-danger" href="/settings/force-log-out">{$t("settings.force-log-out-button")}</a>
|
<a class="btn btn-danger" href="/settings/force-log-out">{$t("settings.force-log-out-button")}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !data.user.deleted}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h4>Delete your account</h4>
|
||||||
|
<p></p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4>{$t("settings.table-title")}</h4>
|
<h4>{$t("settings.table-title")}</h4>
|
||||||
|
|
||||||
|
|
|
@ -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! };
|
||||||
|
};
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-lg-75 mx-auto">
|
||||||
|
<h3>{$t("settings.reactivated-header")}</h3>
|
||||||
|
|
||||||
|
<p>{$t("settings.reactivated-explanation")}</p>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/settings" class="btn btn-primary">{$t("edit-profile.back-to-settings-tab")}</a>
|
||||||
|
<a href="/@{data.user.username}" class="btn btn-secondary">
|
||||||
|
{$t("error.back-to-profile-button")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
Loading…
Reference in a new issue