diff --git a/messages/de-DE.json b/messages/de-DE.json index 84584f5..4a36f53 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -422,6 +422,21 @@ "notSet": "Nicht festgelegt", "memberSince": "Mitglied seit", "logout": "Abmelden", + "deleteAccount": { + "button": "Konto löschen", + "title": "Konto löschen", + "warning": "Diese Aktion ist unwiderruflich. Alle Ihre Daten werden dauerhaft gelöscht.", + "warningDecks": "Alle Ihre Decks und Karten", + "warningCards": "All Ihr Lernfortschritt", + "warningHistory": "All Ihr Übersetzungs- und Wörterbuchverlauf", + "warningPermanent": "Diese Aktion kann nicht rückgängig gemacht werden", + "confirmLabel": "Geben Sie Ihren Benutzernamen zur Bestätigung ein:", + "usernameMismatch": "Benutzername stimmt nicht überein", + "cancel": "Abbrechen", + "confirm": "Mein Konto löschen", + "success": "Konto erfolgreich gelöscht", + "failed": "Konto konnte nicht gelöscht werden" + }, "folders": { "title": "Decks", "noFolders": "Noch keine Decks", diff --git a/messages/en-US.json b/messages/en-US.json index 082fcf9..141b153 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -428,6 +428,21 @@ "memberSince": "Member Since", "joined": "Joined", "logout": "Logout", + "deleteAccount": { + "button": "Delete Account", + "title": "Delete Account", + "warning": "This action is irreversible. All your data will be permanently deleted.", + "warningDecks": "All your decks and cards", + "warningCards": "All your learning progress", + "warningHistory": "All your translation and dictionary history", + "warningPermanent": "This action cannot be undone", + "confirmLabel": "Type your username to confirm:", + "usernameMismatch": "Username does not match", + "cancel": "Cancel", + "confirm": "Delete My Account", + "success": "Account deleted successfully", + "failed": "Failed to delete account" + }, "decks": { "title": "Decks", "noDecks": "No decks yet", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index af78506..c2434e2 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -422,6 +422,21 @@ "notSet": "Non défini", "memberSince": "Membre depuis", "logout": "Déconnexion", + "deleteAccount": { + "button": "Supprimer le compte", + "title": "Supprimer le compte", + "warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.", + "warningDecks": "Tous vos decks et cartes", + "warningCards": "Tout votre progression d'apprentissage", + "warningHistory": "Tout votre historique de traduction et de dictionnaire", + "warningPermanent": "Cette action ne peut pas être annulée", + "confirmLabel": "Tapez votre nom d'utilisateur pour confirmer :", + "usernameMismatch": "Le nom d'utilisateur ne correspond pas", + "cancel": "Annuler", + "confirm": "Supprimer mon compte", + "success": "Compte supprimé avec succès", + "failed": "Échec de la suppression du compte" + }, "decks": { "title": "Decks", "noDecks": "Pas encore de decks", diff --git a/messages/it-IT.json b/messages/it-IT.json index 56dce91..7128995 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -422,6 +422,21 @@ "notSet": "Non Impostato", "memberSince": "Membro Dal", "logout": "Esci", + "deleteAccount": { + "button": "Elimina Account", + "title": "Elimina Account", + "warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.", + "warningDecks": "Tutti i tuoi mazzi e le tue carte", + "warningCards": "Tutto il tuo progresso di apprendimento", + "warningHistory": "Tutto il tuo cronologia di traduzione e dizionario", + "warningPermanent": "Questa azione non può essere annullata", + "confirmLabel": "Digita il tuo nome utente per confermare:", + "usernameMismatch": "Il nome utente non corrisponde", + "cancel": "Annulla", + "confirm": "Elimina il mio account", + "success": "Account eliminato con successo", + "failed": "Impossibile eliminare l'account" + }, "decks": { "title": "Mazzi", "noDecks": "Nessun mazzo ancora", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index e868fec..7dd24b5 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -413,6 +413,21 @@ "notSet": "未設定", "memberSince": "登録日", "logout": "ログアウト", + "deleteAccount": { + "button": "アカウント削除", + "title": "アカウント削除", + "warning": "この操作は取り消せません。すべてのデータが完全に削除されます。", + "warningDecks": "すべてのデッキとカード", + "warningCards": "すべての学習履歴", + "warningHistory": "すべての翻訳と辞書の履歴", + "warningPermanent": "この操作は取り消せません", + "confirmLabel": "確認のためユーザー名を入力してください:", + "usernameMismatch": "ユーザー名が一致しません", + "cancel": "キャンセル", + "confirm": "アカウントを削除する", + "success": "アカウントが正常に削除されました", + "failed": "アカウントの削除に失敗しました" + }, "decks": { "title": "デッキ", "noDecks": "まだデッキがありません", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 4525399..f7e127b 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -422,6 +422,21 @@ "notSet": "설정되지 않음", "memberSince": "가입일", "logout": "로그아웃", + "deleteAccount": { + "button": "계정 삭제", + "title": "계정 삭제", + "warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.", + "warningDecks": "모든 덱과 카드", + "warningCards": "모든 학습 진행 상황", + "warningHistory": "모든 번역 및 사전 기록", + "warningPermanent": "이 작업은 취소할 수 없습니다", + "confirmLabel": "확인을 위해 사용자명을 입력하세요:", + "usernameMismatch": "사용자명이 일치하지 않습니다", + "cancel": "취소", + "confirm": "내 계정 삭제", + "success": "계정이 성공적으로 삭제되었습니다", + "failed": "계정 삭제에 실패했습니다" + }, "folders": { "title": "덱", "noFolders": "아직 덱이 없습니다", diff --git a/messages/ug-CN.json b/messages/ug-CN.json index 0eb8b1c..521f8c2 100644 --- a/messages/ug-CN.json +++ b/messages/ug-CN.json @@ -422,6 +422,21 @@ "notSet": "تەڭشەلمىگەن", "memberSince": "ئەزا بولغاندىن بېرى", "logout": "چىكىنىش", + "deleteAccount": { + "button": "ھېساباتنى ئۆچۈرۈش", + "title": "ھېساباتنى ئۆچۈرۈش", + "warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.", + "warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز", + "warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز", + "warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز", + "warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ", + "confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:", + "usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ", + "cancel": "بىكار قىلىش", + "confirm": "ھېساباتىمنى ئۆچۈرۈش", + "success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى", + "failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى" + }, "decks": { "title": "دېكلار", "noDecks": "تېخى دېك يوق", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 7b3d4f9..2cb21e3 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -428,6 +428,21 @@ "memberSince": "注册时间", "joined": "加入于", "logout": "登出", + "deleteAccount": { + "button": "注销账号", + "title": "注销账号", + "warning": "此操作不可逆,您的所有数据将被永久删除。", + "warningDecks": "您的所有牌组和卡片", + "warningCards": "您的所有学习进度", + "warningHistory": "您的所有翻译和词典历史", + "warningPermanent": "此操作无法撤销", + "confirmLabel": "输入您的用户名以确认:", + "usernameMismatch": "用户名不匹配", + "cancel": "取消", + "confirm": "注销我的账号", + "success": "账号已成功注销", + "failed": "注销账号失败" + }, "decks": { "title": "牌组", "noDecks": "还没有牌组", diff --git a/src/app/(auth)/users/[username]/DeleteAccountButton.tsx b/src/app/(auth)/users/[username]/DeleteAccountButton.tsx new file mode 100644 index 0000000..17e8f97 --- /dev/null +++ b/src/app/(auth)/users/[username]/DeleteAccountButton.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Button } from "@/design-system/base/button"; +import { Modal } from "@/design-system/overlay/modal"; +import { actionDeleteAccount } from "@/modules/auth/auth-action"; + +interface DeleteAccountButtonProps { + username: string; +} + +export function DeleteAccountButton({ username }: DeleteAccountButtonProps) { + const t = useTranslations("user_profile"); + const router = useRouter(); + const [showModal, setShowModal] = useState(false); + const [confirmUsername, setConfirmUsername] = useState(""); + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + if (confirmUsername !== username) { + toast.error(t("deleteAccount.usernameMismatch")); + return; + } + + setLoading(true); + try { + const result = await actionDeleteAccount(); + if (result.success) { + toast.success(t("deleteAccount.success")); + router.push("/"); + } else { + toast.error(result.message || t("deleteAccount.failed")); + } + } catch { + toast.error(t("deleteAccount.failed")); + } finally { + setLoading(false); + setShowModal(false); + } + }; + + return ( + <> + + + setShowModal(false)}> +
+

+ {t("deleteAccount.title")} +

+ +
+

+ {t("deleteAccount.warning")} +

+ +
    +
  • {t("deleteAccount.warningDecks")}
  • +
  • {t("deleteAccount.warningCards")}
  • +
  • {t("deleteAccount.warningHistory")}
  • +
  • {t("deleteAccount.warningPermanent")}
  • +
+ +
+ + setConfirmUsername(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500" + placeholder={username} + /> +
+
+ +
+ + +
+
+
+ + ); +} diff --git a/src/app/(auth)/users/[username]/page.tsx b/src/app/(auth)/users/[username]/page.tsx index 55b3ed9..216287b 100644 --- a/src/app/(auth)/users/[username]/page.tsx +++ b/src/app/(auth)/users/[username]/page.tsx @@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server"; import { auth } from "@/auth"; import { headers } from "next/headers"; import { FollowStats } from "@/components/follow/FollowStats"; +import { DeleteAccountButton } from "./DeleteAccountButton"; interface UserPageProps { params: Promise<{ username: string; }>; @@ -45,7 +46,14 @@ export default async function UserPage({ params }: UserPageProps) {
- {isOwnProfile && {t("logout")}} +
+ {isOwnProfile && ( + <> + {t("logout")} + + + )} +
{user.image ? ( diff --git a/src/modules/auth/auth-action-dto.ts b/src/modules/auth/auth-action-dto.ts index 26dd025..18cbd17 100644 --- a/src/modules/auth/auth-action-dto.ts +++ b/src/modules/auth/auth-action-dto.ts @@ -61,3 +61,8 @@ export type ActionOutputUserProfile = { updatedAt: Date; }; }; + +export type ActionOutputDeleteAccount = { + success: boolean; + message: string; +}; diff --git a/src/modules/auth/auth-action.ts b/src/modules/auth/auth-action.ts index 4467502..b491209 100644 --- a/src/modules/auth/auth-action.ts +++ b/src/modules/auth/auth-action.ts @@ -5,11 +5,13 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { ValidateError } from "@/lib/errors"; import { createLogger } from "@/lib/logger"; +import { prisma } from "@/lib/db"; import { ActionInputGetUserProfileByUsername, ActionInputSignIn, ActionInputSignUp, ActionOutputAuth, + ActionOutputDeleteAccount, ActionOutputUserProfile, validateActionInputGetUserProfileByUsername, validateActionInputSignIn, @@ -180,3 +182,91 @@ export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProf }; } } + +/** + * Delete account action + * Permanently deletes the current user and all associated data + */ +export async function actionDeleteAccount(): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return { success: false, message: "Unauthorized" }; + } + + const userId = session.user.id; + + await prisma.$transaction(async (tx) => { + // Delete in correct order to avoid foreign key constraints + // 1. Revlogs (depend on cards) + await tx.revlog.deleteMany({ + where: { card: { note: { userId } } } + }); + + // 2. Cards (depend on notes and decks) + await tx.card.deleteMany({ + where: { note: { userId } } + }); + + // 3. Notes (depend on note types) + await tx.note.deleteMany({ + where: { userId } + }); + + // 4. Note types + await tx.noteType.deleteMany({ + where: { userId } + }); + + // 5. Deck favorites + await tx.deckFavorite.deleteMany({ + where: { userId } + }); + + // 6. Decks + await tx.deck.deleteMany({ + where: { userId } + }); + + // 7. Follows (both as follower and following) + await tx.follow.deleteMany({ + where: { + OR: [ + { followerId: userId }, + { followingId: userId } + ] + } + }); + + // 8. Dictionary lookups + await tx.dictionaryLookUp.deleteMany({ + where: { userId } + }); + + // 9. Translation history + await tx.translationHistory.deleteMany({ + where: { userId } + }); + + // 10. Sessions + await tx.session.deleteMany({ + where: { userId } + }); + + // 11. Accounts + await tx.account.deleteMany({ + where: { userId } + }); + + // 12. Finally, delete the user + await tx.user.delete({ + where: { id: userId } + }); + }); + + return { success: true, message: "Account deleted successfully" }; + } catch (e) { + log.error("Delete account failed", { error: e }); + return { success: false, message: "Failed to delete account" }; + } +}