Compare commits
5 commits
546e900204
...
e24c4f9b00
Author | SHA1 | Date | |
---|---|---|---|
e24c4f9b00 | |||
3f8f6d0f23 | |||
661c3eab0f | |||
96725cc304 | |||
8a2ffd7d69 |
25 changed files with 506 additions and 23 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,
|
||||||
|
@ -57,7 +58,7 @@ public class ExportsController(
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ExportUrl(Snowflake userId, string filename) =>
|
private string ExportUrl(Snowflake userId, string filename) =>
|
||||||
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
|
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}/data-export.zip";
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> QueueDataExportAsync()
|
public async Task<IActionResult> QueueDataExportAsync()
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -220,5 +220,5 @@ public class CreateDataExportInvocable(
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExportPath(Snowflake userId, string b64) =>
|
private static string ExportPath(Snowflake userId, string b64) =>
|
||||||
$"data-exports/{userId}/{b64}.zip";
|
$"data-exports/{userId}/{b64}/data-export.zip";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,5 +128,5 @@ public class DataCleanupService(
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExportPath(Snowflake userId, string b64) =>
|
private static string ExportPath(Snowflake userId, string b64) =>
|
||||||
$"data-exports/{userId}/{b64}.zip";
|
$"data-exports/{userId}/{b64}/data-export.zip";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { type User, type Member, type CustomPreference } from "$api/models";
|
||||||
|
import StatusIcon from "$components/StatusIcon.svelte";
|
||||||
|
|
||||||
|
type Props = { profile: User | Member; allPreferences: Record<string, CustomPreference> };
|
||||||
|
let { profile, allPreferences }: Props = $props();
|
||||||
|
|
||||||
|
let preferences = $derived.by(() => {
|
||||||
|
let preferenceKeys = Object.keys(allPreferences).filter(
|
||||||
|
(pref) =>
|
||||||
|
profile.names.some((entry) => entry.status === pref) ||
|
||||||
|
profile.pronouns.some((entry) => entry.status === pref) ||
|
||||||
|
profile.fields.some((field) => field.entries.some((entry) => entry.status === pref)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return preferenceKeys.map((pref) => allPreferences[pref]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center text-body-secondary">
|
||||||
|
<ul class="list-inline">
|
||||||
|
{#each preferences as preference}
|
||||||
|
<li class="list-inline-item mx-2">
|
||||||
|
<StatusIcon {preference} />
|
||||||
|
{preference.tooltip}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
|
@ -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,44 @@
|
||||||
"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!",
|
||||||
|
"force-delete-input-label": "To delete your account, type your username (@{{username}}), including the @, in the box below:",
|
||||||
|
"force-delete-export-hint": "If you haven't done so yet, we recommend you download an export of your data before continuing:",
|
||||||
|
"force-delete-export-link": "export your data",
|
||||||
|
"force-delete-irreversible": "This process is irreversible.",
|
||||||
|
"force-delete-username-available": "Your username will immediately be available for other users to take.",
|
||||||
|
"force-delete-immediate-delete": "This will immediately delete all of your profiles, including avatars.",
|
||||||
|
"force-delete-page-explanation": "Your account is currently pending deletion. If you want all your data deleted immediately, you can do so here.",
|
||||||
|
"force-delete-page-header": "Permanently delete your account",
|
||||||
|
"force-delete-checkbox-label": "Yes, I understand that my data will be permanently deleted and cannot be recovered.",
|
||||||
|
"force-delete-page-button": "Delete my account",
|
||||||
|
"account-is-deleted-header": "Your account has been deleted",
|
||||||
|
"account-is-deleted-permanently-description": "Your account has been deleted. Note that it may take a few minutes for all of your data to be removed.",
|
||||||
|
"account-is-deleted-close-page": "You may now close this page.",
|
||||||
|
"soft-delete-button": "Deactivate your account",
|
||||||
|
"soft-delete-hint": "If you want to delete your account, use the button below.",
|
||||||
|
"soft-delete-header": "Deactivate your account",
|
||||||
|
"force-delete-page-cancel": "I changed my mind, cancel",
|
||||||
|
"soft-delete-page-header": "Deactivate your account",
|
||||||
|
"soft-delete-page-explanation": "If you want to delete your account, you can do so here.",
|
||||||
|
"soft-delete-90-days": "Your account will be permanently deleted after 90 days.",
|
||||||
|
"soft-delete-can-reactivate": "If you change your mind, you can log in and go to the settings page at any time to reactivate your account.",
|
||||||
|
"soft-delete-keep-username": "You will keep your current username until your account is permanently deleted.",
|
||||||
|
"soft-delete-can-delete-permanently": "If you want to delete all your data early, you can do so by logging in and going to the settings page.",
|
||||||
|
"soft-delete-page-button": "Deactivate my account",
|
||||||
|
"soft-delete-input-label": "To deactivate your account, type your username (@{{username}}), including the @, in the box below:",
|
||||||
|
"account-is-deactivated-header": "Your account has been deactivated",
|
||||||
|
"account-is-deactivated-description": "Your account has been deactivated, and will be deleted in 90 days. If you change your mind, just log in again, and you will have the option to reactivate your account. If you want to delete your data immediately, you should also log in again, and you will be able to request immediate deletion."
|
||||||
},
|
},
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import Paginator from "$components/Paginator.svelte";
|
import Paginator from "$components/Paginator.svelte";
|
||||||
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
||||||
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
|
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
|
||||||
|
import PreferenceCheatsheet from "$components/profile/PreferenceCheatsheet.svelte";
|
||||||
|
|
||||||
type Props = { data: PageData };
|
type Props = { data: PageData };
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
@ -28,7 +29,7 @@
|
||||||
|
|
||||||
<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
|
<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
|
||||||
<ProfileFields profile={data.user} {allPreferences} />
|
<ProfileFields profile={data.user} {allPreferences} />
|
||||||
|
<PreferenceCheatsheet profile={data.user} {allPreferences} />
|
||||||
<ProfileButtons
|
<ProfileButtons
|
||||||
meUser={data.meUser}
|
meUser={data.meUser}
|
||||||
user={data.user.username}
|
user={data.user.username}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import { Icon } from "@sveltestrap/sveltestrap";
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
|
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
|
||||||
|
import PreferenceCheatsheet from "$components/profile/PreferenceCheatsheet.svelte";
|
||||||
|
|
||||||
type Props = { data: PageData };
|
type Props = { data: PageData };
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
@ -38,7 +39,7 @@
|
||||||
|
|
||||||
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
|
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
|
||||||
<ProfileFields profile={data.member} {allPreferences} />
|
<ProfileFields profile={data.member} {allPreferences} />
|
||||||
|
<PreferenceCheatsheet profile={data.member} {allPreferences} />
|
||||||
<ProfileButtons
|
<ProfileButtons
|
||||||
meUser={data.meUser}
|
meUser={data.meUser}
|
||||||
user={data.member.user.username}
|
user={data.member.user.username}
|
||||||
|
|
|
@ -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,14 @@
|
||||||
<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>{$t("settings.soft-delete-header")}</h4>
|
||||||
|
<p>{$t("settings.soft-delete-hint")}</p>
|
||||||
|
<a href="/settings/delete" class="btn btn-danger">{$t("settings.soft-delete-button")}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4>{$t("settings.table-title")}</h4>
|
<h4>{$t("settings.table-title")}</h4>
|
||||||
|
|
||||||
|
|
41
Foxnouns.Frontend/src/routes/settings/delete/+page.server.ts
Normal file
41
Foxnouns.Frontend/src/routes/settings/delete/+page.server.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||||
|
import { clearToken } from "$lib";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = async ({ parent }) => {
|
||||||
|
const { meUser } = await parent();
|
||||||
|
if (!meUser) redirect(303, "/");
|
||||||
|
|
||||||
|
if (meUser.deleted)
|
||||||
|
throw new ApiError({
|
||||||
|
message: "You cannot use this page.",
|
||||||
|
status: 403,
|
||||||
|
code: ErrorCode.Forbidden,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: meUser! };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const username = body.get("username") as string;
|
||||||
|
const currentUsername = body.get("current-username") as string;
|
||||||
|
|
||||||
|
if (!username || username !== currentUsername) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
message: "Username doesn't match your username.",
|
||||||
|
status: 400,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
} as RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await fastRequest("POST", "/self-delete/delete", { fetch, cookies, isInternal: true });
|
||||||
|
clearToken(cookies);
|
||||||
|
redirect(303, "/settings/delete/success");
|
||||||
|
},
|
||||||
|
};
|
55
Foxnouns.Frontend/src/routes/settings/delete/+page@.svelte
Normal file
55
Foxnouns.Frontend/src/routes/settings/delete/+page@.svelte
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("settings.soft-delete-page-header")} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="w-lg-75 mx-auto">
|
||||||
|
<h3>{$t("settings.soft-delete-page-header")}</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{$t("settings.soft-delete-page-explanation")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>{$t("settings.soft-delete-90-days")}</li>
|
||||||
|
<li>
|
||||||
|
{$t("settings.soft-delete-can-reactivate")}
|
||||||
|
</li>
|
||||||
|
<li>{$t("settings.soft-delete-keep-username")}</li>
|
||||||
|
<li>
|
||||||
|
{$t("settings.soft-delete-can-delete-permanently")}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
<p>
|
||||||
|
{$t("settings.soft-delete-input-label", { username: data.user.username })}
|
||||||
|
<input
|
||||||
|
class="form-control mt-2"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
placeholder="@{data.user.username}"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<input type="hidden" value="@{data.user.username}" readonly name="current-username" />
|
||||||
|
</p>
|
||||||
|
<div class="btn-group mb-2">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
{$t("settings.soft-delete-page-button")}
|
||||||
|
</button>
|
||||||
|
<a href="/settings" class="btn btn-secondary">{$t("settings.force-delete-page-cancel")}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("settings.soft-delete-page-header")} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="w-lg-75 mx-auto">
|
||||||
|
<h3>{$t("settings.account-is-deactivated-header")}</h3>
|
||||||
|
<p>
|
||||||
|
{$t("settings.account-is-deactivated-description")}
|
||||||
|
</p>
|
||||||
|
<p>{$t("settings.account-is-deleted-close-page")}</p>
|
||||||
|
<p>
|
||||||
|
<a href="/" class="btn btn-secondary">{$t("error.back-to-main-page-button")}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -48,7 +48,6 @@
|
||||||
|
|
||||||
const idx = flags.findIndex((f) => f.id === id);
|
const idx = flags.findIndex((f) => f.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
console.log("yippee");
|
|
||||||
flags[idx] = { ...flags[idx], name, description };
|
flags[idx] = { ...flags[idx], name, description };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error("Could not update flag %s:", id, e);
|
log.error("Could not update flag %s:", id, e);
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||||
|
import { clearToken } from "$lib";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = async ({ parent }) => {
|
||||||
|
const { meUser } = await parent();
|
||||||
|
if (!meUser) redirect(303, "/");
|
||||||
|
|
||||||
|
if (!meUser.deleted)
|
||||||
|
throw new ApiError({
|
||||||
|
message: "You cannot use this page.",
|
||||||
|
status: 403,
|
||||||
|
code: ErrorCode.Forbidden,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: meUser! };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const username = body.get("username") as string;
|
||||||
|
const currentUsername = body.get("current-username") as string;
|
||||||
|
const confirmed = !!body.get("confirm");
|
||||||
|
|
||||||
|
if (!username || username !== currentUsername) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
message: "Username doesn't match your username.",
|
||||||
|
status: 400,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
} as RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
message: "You must check the box to continue.",
|
||||||
|
status: 400,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
} as RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await fastRequest("POST", "/self-delete/force", { fetch, cookies, isInternal: true });
|
||||||
|
clearToken(cookies);
|
||||||
|
redirect(303, "/settings/force-delete/success");
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("settings.force-delete-page-header")} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="w-lg-75 mx-auto">
|
||||||
|
<h3>{$t("settings.force-delete-page-header")}</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{$t("settings.force-delete-page-explanation")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>{$t("settings.force-delete-immediate-delete")}</li>
|
||||||
|
<li>{$t("settings.force-delete-username-available")}</li>
|
||||||
|
<li><strong>{$t("settings.force-delete-irreversible")}</strong></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{$t("settings.force-delete-export-hint")}
|
||||||
|
<a href="/settings/export">{$t("settings.force-delete-export-link")}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
<p>
|
||||||
|
{$t("settings.force-delete-input-label", { username: data.user.username })}
|
||||||
|
<input
|
||||||
|
class="form-control mt-2"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
placeholder="@{data.user.username}"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<input type="hidden" value="@{data.user.username}" readonly name="current-username" />
|
||||||
|
</p>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
value="yes"
|
||||||
|
name="confirm"
|
||||||
|
id="confirm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="confirm">
|
||||||
|
{$t("settings.force-delete-checkbox-label")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group mt-3 mb-2">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
{$t("settings.force-delete-page-button")}
|
||||||
|
</button>
|
||||||
|
<a href="/settings" class="btn btn-secondary">{$t("settings.force-delete-page-cancel")}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("settings.force-delete-page-header")} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="w-lg-75 mx-auto">
|
||||||
|
<h3>{$t("settings.account-is-deleted-header")}</h3>
|
||||||
|
<p>
|
||||||
|
{$t("settings.account-is-deleted-permanently-description")}
|
||||||
|
</p>
|
||||||
|
<p>{$t("settings.account-is-deleted-close-page")}</p>
|
||||||
|
<p>
|
||||||
|
<a href="/" class="btn btn-secondary">{$t("error.back-to-main-page-button")}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<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>
|
||||||
|
</div>
|
Loading…
Reference in a new issue