diff --git a/src/components/mobile/screens/MobileAccountPage.tsx b/src/components/mobile/screens/MobileAccountPage.tsx index e84729bea..c9f566a2b 100644 --- a/src/components/mobile/screens/MobileAccountPage.tsx +++ b/src/components/mobile/screens/MobileAccountPage.tsx @@ -16,6 +16,7 @@ import { useServerBillingEnabled } from "@/services/serverCapabilities"; import { useSeatReadout } from "@/hooks/useSeatReadout"; import { seatsUsedText } from "@/services/seatMath"; import { formatOptionalDate, SHORT_DATE } from "@/utils/localeFormat"; +import { DeleteAccountModal } from "@/components/shared/DeleteAccountModal"; export default function MobileAccountPage() { const { t } = useTranslation(); @@ -43,6 +44,7 @@ export default function MobileAccountPage() { const sync = useSyncProviders().effective; const [email, setEmail] = useState(null); const [confirming, setConfirming] = useState(false); + const [deletingAccount, setDeletingAccount] = useState(false); const [checkoutBusy, setCheckoutBusy] = useState(false); useEffect(() => { void getCurrentUserEmail().then(setEmail); }, []); @@ -190,7 +192,21 @@ export default function MobileAccountPage() { {t("mobile.account.signOut")} )} + {signedIn && !confirming && ( + + )} + {deletingAccount && ( + setDeletingAccount(false)} onDeleted={() => { setDeletingAccount(false); pop(); }} /> + )} ); diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index a9d2d2143..33620ea4f 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -25,6 +25,7 @@ import { SessionSecuritySettings } from "./SessionSecuritySettings"; import ChangeMasterPasswordModal from "./ChangeMasterPasswordModal"; import { TwoFactorSettings } from "./TwoFactorSettings"; import { formatOptionalDate, SHORT_DATE } from "@/utils/localeFormat"; +import { DeleteAccountModal } from "@/components/shared/DeleteAccountModal"; type AccountStep = "idle" | "set-password" | "loading" | "confirm-wipe"; @@ -119,6 +120,7 @@ export default function AccountSection() { const [showEditEmail, setShowEditEmail] = useState(false); const { requestSignOut, signOutConfirm } = useSignOutConfirm((e) => setError(e instanceof Error ? e.message : String(e))); const [showChangePassword, setShowChangePassword] = useState(false); + const [showDeleteAccount, setShowDeleteAccount] = useState(false); const [handle, setHandle] = useState(null); const [handleIsCustom, setHandleIsCustom] = useState(false); const [handleManaged, setHandleManaged] = useState(false); @@ -390,6 +392,18 @@ export default function AccountSection() { }} /> )} + {mode === "server" && ( + { + setError(""); + setShowDeleteAccount(true); + }} + /> + )} setShowDeleteAccount(false)} onDeleted={() => window.location.reload()} /> + )} + {showEditEmail && currentEmail && ( void; + /** Runs once the account is gone and this device is signed out (desktop reloads, mobile pops). */ + onDeleted: () => void; +} + +/** + * Permanently deletes the signed-in cloud account (DELETE /v1/auth/account), + * then signs this device out through the regular logout path. Offered in every + * build: store policy requires in-app account deletion. + */ +export function DeleteAccountModal({ onCancel, onDeleted }: Props) { + const { t } = useTranslation(); + // `undefined` while the keychain answers, so the field never asks for the wrong thing. + const [email, setEmail] = useState(undefined); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [deleted, setDeleted] = useState(false); + const [error, setError] = useState(""); + const billingEnabled = useServerBillingEnabled(); + const status = useSubscriptionStore((s) => s.subscriptionStatus); + const cancelled = useSubscriptionStore((s) => s.subscriptionCancelled); + + useEffect(() => { + void getCurrentUserEmail().then(setEmail, () => setEmail(null)); + }, []); + + if (deleted) { + // Held open until acknowledged: the desktop reload that follows would swallow the toast. + return ( + + +
+ +

{t("shared.deleteAccount.deletedTitle")}

+
+

{t("shared.deleteAccount.deleted")}

+
+ +
+
+
+ ); + } + + const loaded = email !== undefined; + const confirmed = loaded && isDeleteConfirmed(input, email); + const submit = () => { + if (!confirmed || busy) return; + setBusy(true); + setError(""); + deleteAccount() + .then(() => { + appToast("success", t("shared.deleteAccount.deleted")); + setDeleted(true); + }) + .catch((e: unknown) => { + setError(e instanceof Error ? e.message : String(e)); + setBusy(false); + }); + }; + + return ( + {} : onCancel} + > +

{t("shared.deleteAccount.deviceNote")}

+ {subscriptionRenews({ billingEnabled, status, cancelled }) && ( +

{t("shared.deleteAccount.subscriptionNote")}

+ )} + {loaded && ( + + )} + {error &&

{error}

} +
+ ); +} diff --git a/src/i18n/locales/cs/shared.json b/src/i18n/locales/cs/shared.json index 3cbf36506..3ad32e3a5 100644 --- a/src/i18n/locales/cs/shared.json +++ b/src/i18n/locales/cs/shared.json @@ -6,6 +6,22 @@ "unsynced": "Synchronizace je pozastavena nebo se nepodařilo spojit se serverem, takže nedávné změny z tohoto zařízení na něm nemusí být uloženy. Odhlášením vymažete kopii v tomto zařízení.", "confirm": "Vymazat a odhlásit se" }, + "deleteAccount": { + "action": "Smazat účet", + "actionSub": "Trvale smaže váš cloudový účet Voltius a jeho data", + "title": "Smazat váš účet Voltius?", + "message": "Tímto trvale smažete svůj cloudový účet, šifrovaný trezor a všechna synchronizovaná data ze serveru Voltius. Zároveň opustíte všechny týmy, jejichž jste členem. Tuto akci nelze vrátit.", + "deviceNote": "Toto zařízení se poté odhlásí a jeho místní kopie vašich hostitelů, klíčů a snippetů se vymaže, stejně jako při odhlášení. Kopie na vašich dalších zařízeních se na dálku neodstraní.", + "subscriptionNote": "Vaše předplatné se zruší spolu s účtem: neobnoví se a nebudou vám účtovány žádné další platby.", + "typeToConfirm": "Pro potvrzení napište {{target}}", + "confirm": "Smazat účet", + "deleting": "Mazání…", + "deletedTitle": "Účet smazán", + "deleted": "Váš účet Voltius byl smazán.", + "failed": "Účet se nepodařilo smazat (chyba {{status}}). Zkuste to později nebo kontaktujte podporu.", + "ownsTeamWithMembers": "Vlastníte tým, který má stále další členy ({{teams}}). Odeberte tyto členy nebo tým smažte a zkuste to znovu.", + "subscriptionCancelFailed": "Předplatné se nepodařilo zrušit, proto účet nebyl smazán. Zkuste to za pár minut nebo kontaktujte podporu." + }, "errorBanner": { "dismiss": "zavřít" }, diff --git a/src/i18n/locales/en/shared.json b/src/i18n/locales/en/shared.json index c66e57ead..5b1e005c8 100644 --- a/src/i18n/locales/en/shared.json +++ b/src/i18n/locales/en/shared.json @@ -6,6 +6,22 @@ "unsynced": "Sync is paused or could not reach the server, so recent changes on this device may not be saved there. Signing out erases this device's copy.", "confirm": "Erase and sign out" }, + "deleteAccount": { + "action": "Delete account", + "actionSub": "Permanently delete your Voltius cloud account and its data", + "title": "Delete your Voltius account?", + "message": "This permanently deletes your cloud account, your encrypted vault and all cloud sync data from the Voltius server. You also leave any team you are a member of. This cannot be undone.", + "deviceNote": "This device is then signed out and its local copy of your hosts, keys and snippets is erased, as with Sign out. Copies on your other devices are not removed remotely.", + "subscriptionNote": "Your subscription is cancelled along with your account: it will not renew and you will not be charged again.", + "typeToConfirm": "Type {{target}} to confirm", + "confirm": "Delete account", + "deleting": "Deleting…", + "deletedTitle": "Account deleted", + "deleted": "Your Voltius account has been deleted.", + "failed": "Could not delete your account (error {{status}}). Try again later, or contact support.", + "ownsTeamWithMembers": "You own a team that still has other members ({{teams}}). Remove those members or delete the team, then try again.", + "subscriptionCancelFailed": "Your subscription could not be cancelled, so your account was not deleted. Try again in a few minutes, or contact support." + }, "errorBanner": { "dismiss": "dismiss" }, diff --git a/src/i18n/locales/fr/shared.json b/src/i18n/locales/fr/shared.json index 9eb308ce6..52728841e 100644 --- a/src/i18n/locales/fr/shared.json +++ b/src/i18n/locales/fr/shared.json @@ -6,6 +6,22 @@ "unsynced": "La synchronisation est en pause ou n'a pas pu joindre le serveur : les modifications récentes de cet appareil n'y sont peut-être pas enregistrées. La déconnexion efface la copie de cet appareil.", "confirm": "Effacer et se déconnecter" }, + "deleteAccount": { + "action": "Supprimer le compte", + "actionSub": "Supprime définitivement votre compte cloud Voltius et ses données", + "title": "Supprimer votre compte Voltius ?", + "message": "Cette action supprime définitivement votre compte cloud, votre coffre chiffré et toutes les données de synchronisation du serveur Voltius. Vous quittez aussi les équipes dont vous êtes membre. Cette action est irréversible.", + "deviceNote": "Cet appareil est ensuite déconnecté et sa copie locale de vos hôtes, clés et snippets est effacée, comme lors d'une déconnexion. Les copies sur vos autres appareils ne sont pas supprimées à distance.", + "subscriptionNote": "Votre abonnement est annulé avec votre compte : il ne sera pas renouvelé et vous ne serez plus prélevé.", + "typeToConfirm": "Saisissez {{target}} pour confirmer", + "confirm": "Supprimer le compte", + "deleting": "Suppression…", + "deletedTitle": "Compte supprimé", + "deleted": "Votre compte Voltius a été supprimé.", + "failed": "Impossible de supprimer votre compte (erreur {{status}}). Réessayez plus tard ou contactez le support.", + "ownsTeamWithMembers": "Vous êtes propriétaire d'une équipe qui compte encore d'autres membres ({{teams}}). Retirez ces membres ou supprimez l'équipe, puis réessayez.", + "subscriptionCancelFailed": "Votre abonnement n'a pas pu être annulé, votre compte n'a donc pas été supprimé. Réessayez dans quelques minutes ou contactez le support." + }, "errorBanner": { "dismiss": "ignorer" }, diff --git a/src/i18n/locales/ru/shared.json b/src/i18n/locales/ru/shared.json index 757b4c757..2ef367923 100644 --- a/src/i18n/locales/ru/shared.json +++ b/src/i18n/locales/ru/shared.json @@ -6,6 +6,22 @@ "unsynced": "Синхронизация приостановлена или сервер был недоступен, поэтому недавние изменения с этого устройства могли не сохраниться на сервере. Выход удалит копию на этом устройстве.", "confirm": "Удалить и выйти" }, + "deleteAccount": { + "action": "Удалить аккаунт", + "actionSub": "Безвозвратно удалить облачный аккаунт Voltius и его данные", + "title": "Удалить аккаунт Voltius?", + "message": "Ваш облачный аккаунт, зашифрованное хранилище и все данные облачной синхронизации будут безвозвратно удалены с сервера Voltius. Вы также покинете все команды, в которых состоите. Это действие нельзя отменить.", + "deviceNote": "Затем на этом устройстве будет выполнен выход, а локальная копия ваших хостов, ключей и сниппетов будет стёрта, как при выходе из аккаунта. Копии на других ваших устройствах удалённо не удаляются.", + "subscriptionNote": "Ваша подписка отменяется вместе с аккаунтом: она не продлится, и оплата больше не будет списываться.", + "typeToConfirm": "Введите {{target}} для подтверждения", + "confirm": "Удалить аккаунт", + "deleting": "Удаление…", + "deletedTitle": "Аккаунт удалён", + "deleted": "Ваш аккаунт Voltius удалён.", + "failed": "Не удалось удалить аккаунт (ошибка {{status}}). Повторите попытку позже или обратитесь в поддержку.", + "ownsTeamWithMembers": "Вы владелец команды, в которой ещё есть другие участники ({{teams}}). Удалите этих участников или саму команду и повторите попытку.", + "subscriptionCancelFailed": "Не удалось отменить подписку, поэтому аккаунт не был удалён. Повторите попытку через несколько минут или обратитесь в поддержку." + }, "errorBanner": { "dismiss": "скрыть" }, diff --git a/src/i18n/locales/tr/shared.json b/src/i18n/locales/tr/shared.json index e715815dc..226598bfd 100644 --- a/src/i18n/locales/tr/shared.json +++ b/src/i18n/locales/tr/shared.json @@ -6,6 +6,22 @@ "unsynced": "Eşitleme duraklatılmış veya sunucuya ulaşılamamış; bu cihazdaki son değişiklikler sunucuya kaydedilmemiş olabilir. Oturumu kapatırsanız bu cihazdaki kopya silinir.", "confirm": "Sil ve oturumu kapat" }, + "deleteAccount": { + "action": "Hesabı sil", + "actionSub": "Voltius bulut hesabınızı ve verilerini kalıcı olarak siler", + "title": "Voltius hesabınız silinsin mi?", + "message": "Bu işlem bulut hesabınızı, şifreli kasanızı ve tüm bulut senkronizasyon verilerinizi Voltius sunucusundan kalıcı olarak siler. Üyesi olduğunuz tüm ekiplerden de ayrılırsınız. Bu işlem geri alınamaz.", + "deviceNote": "Ardından bu cihazda oturum kapatılır ve ana bilgisayarlarınızın, anahtarlarınızın ve snippet'lerinizin yerel kopyası, oturumu kapatırken olduğu gibi silinir. Diğer cihazlarınızdaki kopyalar uzaktan silinmez.", + "subscriptionNote": "Aboneliğiniz hesabınızla birlikte iptal edilir: yenilenmez ve sizden tekrar ücret alınmaz.", + "typeToConfirm": "Onaylamak için {{target}} yazın", + "confirm": "Hesabı sil", + "deleting": "Siliniyor…", + "deletedTitle": "Hesap silindi", + "deleted": "Voltius hesabınız silindi.", + "failed": "Hesabınız silinemedi (hata {{status}}). Daha sonra tekrar deneyin veya destekle iletişime geçin.", + "ownsTeamWithMembers": "Hâlâ başka üyeleri olan bir ekibin sahibisiniz ({{teams}}). Bu üyeleri çıkarın veya ekibi silin, ardından tekrar deneyin.", + "subscriptionCancelFailed": "Aboneliğiniz iptal edilemediği için hesabınız silinmedi. Birkaç dakika sonra tekrar deneyin veya destekle iletişime geçin." + }, "errorBanner": { "dismiss": "kapat" }, diff --git a/src/i18n/locales/zh/shared.json b/src/i18n/locales/zh/shared.json index 5d0c4faef..8685512c6 100644 --- a/src/i18n/locales/zh/shared.json +++ b/src/i18n/locales/zh/shared.json @@ -6,6 +6,22 @@ "unsynced": "同步已暂停或无法连接服务器,此设备上最近的更改可能尚未保存到服务器。退出登录将清除此设备上的副本。", "confirm": "清除并退出登录" }, + "deleteAccount": { + "action": "删除账户", + "actionSub": "永久删除你的 Voltius 云账户及其数据", + "title": "删除你的 Voltius 账户?", + "message": "此操作将从 Voltius 服务器永久删除你的云账户、加密保险库以及所有云同步数据。你也会退出所加入的所有团队。此操作无法撤销。", + "deviceNote": "随后本设备将退出登录,并像退出登录时一样清除本地保存的主机、密钥和代码片段副本。你其他设备上的副本不会被远程删除。", + "subscriptionNote": "你的订阅会随账户一并取消:不会再续订,也不会再向你扣费。", + "typeToConfirm": "输入 {{target}} 以确认", + "confirm": "删除账户", + "deleting": "正在删除…", + "deletedTitle": "账户已删除", + "deleted": "你的 Voltius 账户已被删除。", + "failed": "无法删除你的账户(错误 {{status}})。请稍后重试,或联系支持。", + "ownsTeamWithMembers": "你拥有的团队仍有其他成员({{teams}})。请先移除这些成员或删除该团队,然后重试。", + "subscriptionCancelFailed": "无法取消你的订阅,因此账户未被删除。请几分钟后重试,或联系支持。" + }, "errorBanner": { "dismiss": "关闭" }, diff --git a/src/services/account.deleteAccount.test.ts b/src/services/account.deleteAccount.test.ts new file mode 100644 index 000000000..52975a1bf --- /dev/null +++ b/src/services/account.deleteAccount.test.ts @@ -0,0 +1,85 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + invoke: vi.fn(), + fetchAuth: vi.fn(), + resetVault: vi.fn(async () => undefined), + store: {} as Record, +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); +vi.mock("@/i18n", () => ({ default: { t: (k: string, o?: Record) => (o ? `${k} ${JSON.stringify(o)}` : k) } })); +vi.mock("@/services/http", () => ({ appFetch: vi.fn(), isAbortError: () => false })); +vi.mock("@/services/authFetch", () => ({ fetchAuth: h.fetchAuth })); +vi.mock("./vault", () => ({ + setVaultKey: vi.fn(), + verifyVaultKey: vi.fn(async () => undefined), + lockVault: vi.fn(async () => undefined), + getVaultStatus: vi.fn(async () => ({ exists: false, path: "" })), + unlockVaultIfNeeded: vi.fn(async () => undefined), + wipeLocalConfig: vi.fn(async () => undefined), + resetVault: h.resetVault, +})); +vi.mock("@/stores/subscriptionStore", () => ({ + useSubscriptionStore: { getState: () => ({ load: vi.fn(async () => undefined) }) }, +})); +vi.mock("@/stores/vaultKeysStore", () => ({ + useVaultKeysStore: { getState: () => ({ set: vi.fn(), clear: vi.fn(), dek: null, x25519Private: null }) }, +})); +vi.mock("@/services/sync", () => ({ push: vi.fn(async () => undefined), isSyncPaused: () => false, stopRealtimeSync: vi.fn() })); +vi.mock("@/services/teamDataManager", () => ({ onSessionEnd: vi.fn() })); +vi.mock("@/services/savedAccounts", () => ({ removeSavedAccount: vi.fn(async () => undefined) })); + +import { deleteAccount } from "./account"; + +beforeEach(() => { + vi.clearAllMocks(); + h.store = { account_id: "acct-1", server_url: "https://api.example.test" }; + h.invoke.mockImplementation(async (cmd: string, args: Record = {}) => + cmd === "keychain_get" ? h.store[args.key as string] ?? null : undefined); +}); + +test("deletes the account on the server, then signs this device out", async () => { + h.fetchAuth.mockResolvedValueOnce(new Response(null, { status: 204 })); + await deleteAccount(); + expect(h.fetchAuth).toHaveBeenCalledWith("https://api.example.test/v1/auth/account", { method: "DELETE" }); + expect(h.resetVault).toHaveBeenCalled(); +}); + +/** A failed delete must leave the session alone: the account still exists, so wiping the device would only lose data. */ +test("a server error keeps the device signed in", async () => { + h.fetchAuth.mockResolvedValueOnce(new Response(null, { status: 500 })); + await expect(deleteAccount()).rejects.toThrow(/shared\.deleteAccount\.failed.*500/); + expect(h.resetVault).not.toHaveBeenCalled(); +}); + +test("owning a team with members surfaces the coded refusal", async () => { + h.fetchAuth.mockResolvedValueOnce(new Response( + JSON.stringify({ error: "OWNS_TEAM_WITH_MEMBERS", teams: [{ id: "t1", name: "Ops", other_members: 1 }] }), + { status: 409, headers: { "Content-Type": "application/json" } }, + )); + await expect(deleteAccount()).rejects.toThrow(/shared\.deleteAccount\.ownsTeamWithMembers.*Ops/); + expect(h.resetVault).not.toHaveBeenCalled(); +}); + +test("a failed subscription cancellation keeps the device signed in", async () => { + h.fetchAuth.mockResolvedValueOnce(new Response( + JSON.stringify({ error: "SUBSCRIPTION_CANCEL_FAILED" }), + { status: 502, headers: { "Content-Type": "application/json" } }, + )); + await expect(deleteAccount()).rejects.toThrow("shared.deleteAccount.subscriptionCancelFailed"); + expect(h.resetVault).not.toHaveBeenCalled(); +}); + +test("refuses without a server to talk to", async () => { + h.store = { account_id: "acct-1" }; + await expect(deleteAccount()).rejects.toThrow("common.error.notConnectedToServer"); + expect(h.fetchAuth).not.toHaveBeenCalled(); + expect(h.resetVault).not.toHaveBeenCalled(); +}); + +test("an expired session surfaces without wiping the device", async () => { + h.fetchAuth.mockRejectedValueOnce(new Error("common.error.sessionExpired")); + await expect(deleteAccount()).rejects.toThrow("common.error.sessionExpired"); + expect(h.resetVault).not.toHaveBeenCalled(); +}); diff --git a/src/services/account.ts b/src/services/account.ts index 4e36e7e9b..c97240dcb 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -12,8 +12,10 @@ import { clearSecret, readPlainSecret, readSecret, rememberPassword, replacePass import { rememberServer } from "@/utils/serverInstance"; import { base64ToByteArray, hexToBytes } from "@/utils/base64"; import { EmailUndeliverableError, isEmailUndeliverable } from "@/utils/emailVerification"; +import { deleteAccountFailure } from "@/utils/deleteAccount"; import type { SavedAccount } from "./savedAccounts"; import { clearSessionRevoked, refreshAccessToken } from "./authTokens"; +import { fetchAuth } from "./authFetch"; import { proofBody, twoFactorError, TwoFactorRequiredError, type TwoFactorProof } from "./twoFactorProof"; function reloadSubscription() { @@ -562,6 +564,25 @@ export async function logout(): Promise { await resetVault(); } +/** + * Permanently deletes the signed-in cloud account (DELETE /v1/auth/account), + * then signs this device out. The server cancels a renewing subscription and + * removes the account in one go, or refuses with a coded error (see + * `deleteAccountFailure`). On any failure the session is left intact, since + * the account still exists. + */ +export async function deleteAccount(): Promise { + const serverUrl = await keychainGet("server_url"); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/auth/account`, { method: "DELETE" }); + if (!res.ok) { + const body: unknown = await res.json().catch(() => null); + const { key, params } = deleteAccountFailure(res.status, body); + throw new Error(i18n.t(key, params)); + } + await logout(); +} + export async function getAccountMode(): Promise { return keychainGet("mode"); } diff --git a/src/utils/deleteAccount.test.ts b/src/utils/deleteAccount.test.ts new file mode 100644 index 000000000..f8176a219 --- /dev/null +++ b/src/utils/deleteAccount.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; +import { DELETE_ACCOUNT_KEYWORD, deleteAccountFailure, deleteConfirmationTarget, isDeleteConfirmed, subscriptionRenews } from "./deleteAccount"; + +describe("delete account confirmation", () => { + test("asks for the account email when one is known", () => { + expect(deleteConfirmationTarget("me@example.com")).toBe("me@example.com"); + }); + + test("falls back to the keyword without an email", () => { + expect(deleteConfirmationTarget(null)).toBe(DELETE_ACCOUNT_KEYWORD); + expect(deleteConfirmationTarget(" ")).toBe(DELETE_ACCOUNT_KEYWORD); + }); + + test("accepts the email typed in any case, with stray spaces", () => { + expect(isDeleteConfirmed(" Me@Example.com ", "me@example.com")).toBe(true); + }); + + test("rejects anything short of the full email", () => { + expect(isDeleteConfirmed("", "me@example.com")).toBe(false); + expect(isDeleteConfirmed("me@example", "me@example.com")).toBe(false); + expect(isDeleteConfirmed(DELETE_ACCOUNT_KEYWORD, "me@example.com")).toBe(false); + }); + + test("the keyword fallback is case-sensitive", () => { + expect(isDeleteConfirmed("DELETE", null)).toBe(true); + expect(isDeleteConfirmed("delete", null)).toBe(false); + }); +}); + +describe("subscriptionRenews", () => { + const base = { billingEnabled: true, status: "active", cancelled: false }; + + test("a renewing subscription renews", () => { + expect(subscriptionRenews(base)).toBe(true); + expect(subscriptionRenews({ ...base, status: "on_trial" })).toBe(true); + expect(subscriptionRenews({ ...base, status: "past_due" })).toBe(true); + }); + + test("a cancelled, ended or absent subscription does not", () => { + expect(subscriptionRenews({ ...base, cancelled: true })).toBe(false); + expect(subscriptionRenews({ ...base, status: "cancelled" })).toBe(false); + expect(subscriptionRenews({ ...base, status: "expired" })).toBe(false); + expect(subscriptionRenews({ ...base, status: null })).toBe(false); + }); + + test("self-hosted servers without billing never mention it", () => { + expect(subscriptionRenews({ ...base, billingEnabled: false })).toBe(false); + }); +}); + +describe("deleteAccountFailure", () => { + test("owning a team with members names the teams", () => { + expect(deleteAccountFailure(409, { + error: "OWNS_TEAM_WITH_MEMBERS", + teams: [{ id: "t1", name: "Ops", other_members: 2 }, { id: "t2", name: "Dev", other_members: 1 }], + })).toEqual({ key: "shared.deleteAccount.ownsTeamWithMembers", params: { teams: "Ops, Dev" } }); + }); + + test("a failed subscription cancellation says the account was kept", () => { + expect(deleteAccountFailure(502, { error: "SUBSCRIPTION_CANCEL_FAILED" })) + .toEqual({ key: "shared.deleteAccount.subscriptionCancelFailed", params: {} }); + }); + + test("anything else falls back to the status", () => { + expect(deleteAccountFailure(500, null)).toEqual({ key: "shared.deleteAccount.failed", params: { status: 500 } }); + expect(deleteAccountFailure(500, { error: "ACCOUNT_DELETE_FAILED" })) + .toEqual({ key: "shared.deleteAccount.failed", params: { status: 500 } }); + }); +}); diff --git a/src/utils/deleteAccount.ts b/src/utils/deleteAccount.ts new file mode 100644 index 000000000..e6ebac586 --- /dev/null +++ b/src/utils/deleteAccount.ts @@ -0,0 +1,55 @@ +/** Typed instead of the email when this device has no email on record for the account. */ +export const DELETE_ACCOUNT_KEYWORD = "DELETE"; + +/** What the user must type to confirm: their account email, or the keyword as a fallback. */ +export function deleteConfirmationTarget(email: string | null): string { + return email?.trim() || DELETE_ACCOUNT_KEYWORD; +} + +/** Emails compare case-insensitively (the server lowercases them); the keyword must match exactly. */ +export function isDeleteConfirmed(input: string, email: string | null): boolean { + const target = deleteConfirmationTarget(email); + const typed = input.trim(); + return target === DELETE_ACCOUNT_KEYWORD ? typed === target : typed.toLowerCase() === target.toLowerCase(); +} + +const ENDED_STATUSES = new Set(["cancelled", "expired"]); + +/** + * A subscription still set to renew. The server cancels it as part of the + * deletion (and refuses to delete if it cannot), so the dialog only says so. + */ +export function subscriptionRenews(sub: { + billingEnabled: boolean; + status: string | null; + cancelled: boolean; +}): boolean { + return sub.billingEnabled && !!sub.status && !ENDED_STATUSES.has(sub.status) && !sub.cancelled; +} + +/** An i18n key and its params, for the message shown when the server refuses a deletion. */ +export interface DeleteAccountFailure { + key: string; + params: Record; +} + +/** + * Maps a failed DELETE /v1/auth/account to a message. The server answers + * `{ error: CODE }`: 409 OWNS_TEAM_WITH_MEMBERS (with the blocking `teams`) when + * the user owns a team others still belong to, 502 SUBSCRIPTION_CANCEL_FAILED + * when Lemon Squeezy would not stop the renewal. Anything else is generic. + */ +export function deleteAccountFailure(status: number, body: unknown): DeleteAccountFailure { + const b = (body && typeof body === "object" ? body : {}) as { error?: unknown; teams?: unknown }; + if (b.error === "OWNS_TEAM_WITH_MEMBERS") { + const names = Array.isArray(b.teams) + ? b.teams.map((t) => (t && typeof t === "object" ? (t as { name?: unknown }).name : undefined)) + .filter((n): n is string => typeof n === "string" && n.trim() !== "") + : []; + return { key: "shared.deleteAccount.ownsTeamWithMembers", params: { teams: names.join(", ") || "?" } }; + } + if (b.error === "SUBSCRIPTION_CANCEL_FAILED") { + return { key: "shared.deleteAccount.subscriptionCancelFailed", params: {} }; + } + return { key: "shared.deleteAccount.failed", params: { status } }; +}