Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/components/mobile/screens/MobileAccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function MobileAccountPage() {
const sync = useSyncProviders().effective;
const [email, setEmail] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [deletingAccount, setDeletingAccount] = useState(false);
const [checkoutBusy, setCheckoutBusy] = useState(false);

useEffect(() => { void getCurrentUserEmail().then(setEmail); }, []);
Expand Down Expand Up @@ -190,7 +192,21 @@ export default function MobileAccountPage() {
{t("mobile.account.signOut")}
</button>
)}
{signedIn && !confirming && (
<button
data-account-delete
onClick={() => setDeletingAccount(true)}
className="mt-3 w-full h-11 rounded-xl text-sm font-medium flex items-center justify-center gap-2"
style={{ color: "var(--t-danger, #e5484d)" }}
>
<Icon icon="lucide:user-x" width={16} />
{t("shared.deleteAccount.action")}
</button>
)}
</div>
{deletingAccount && (
<DeleteAccountModal onCancel={() => setDeletingAccount(false)} onDeleted={() => { setDeletingAccount(false); pop(); }} />
)}
</div>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions src/components/settings/sections/AccountSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string | null>(null);
const [handleIsCustom, setHandleIsCustom] = useState(false);
const [handleManaged, setHandleManaged] = useState(false);
Expand Down Expand Up @@ -390,6 +392,18 @@ export default function AccountSection() {
}}
/>
)}
{mode === "server" && (
<ActionItem
icon="lucide:user-x"
label={t("shared.deleteAccount.action")}
sub={t("shared.deleteAccount.actionSub")}
danger
onClick={() => {
setError("");
setShowDeleteAccount(true);
}}
/>
)}
<ActionItem
icon="lucide:trash-2"
label={t("settings.account.wipeData.label")}
Expand All @@ -414,6 +428,10 @@ export default function AccountSection() {

{signOutConfirm}

{showDeleteAccount && (
<DeleteAccountModal onCancel={() => setShowDeleteAccount(false)} onDeleted={() => window.location.reload()} />
)}

{showEditEmail && currentEmail && (
<EditEmailModal
currentEmail={currentEmail}
Expand Down
110 changes: 110 additions & 0 deletions src/components/shared/DeleteAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
import { Modal, ModalCard } from "./Modal";
import { deleteAccount, getCurrentUserEmail } from "@/services/account";
import { appToast } from "@/services/appToast";
import { useServerBillingEnabled } from "@/services/serverCapabilities";
import { useSubscriptionStore } from "@/stores/subscriptionStore";
import { deleteConfirmationTarget, isDeleteConfirmed, subscriptionRenews } from "@/utils/deleteAccount";

interface Props {
onCancel: () => 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<string | null | undefined>(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 (
<Modal onClose={onDeleted} onEnter={onDeleted}>
<ModalCard className="p-6 flex flex-col gap-4 min-w-[21.333rem] max-w-[26.667rem]">
<div className="flex items-center gap-3">
<Icon icon="lucide:circle-check" width={20} className="shrink-0 text-(--t-status-connected)" />
<h2 className="text-sm font-semibold text-(--t-text-bright)">{t("shared.deleteAccount.deletedTitle")}</h2>
</div>
<p className="text-sm text-(--t-text-secondary)">{t("shared.deleteAccount.deleted")}</p>
<div className="flex justify-end">
<button data-delete-account-done onClick={onDeleted} className="btn btn-secondary px-4 py-2 rounded-lg text-sm font-medium">
{t("common.action.close")}
</button>
</div>
</ModalCard>
</Modal>
);
}

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 (
<ConfirmModal
title={t("shared.deleteAccount.title")}
message={t("shared.deleteAccount.message")}
confirmLabel={t("shared.deleteAccount.confirm")}
busy={busy || !confirmed}
busyLabel={busy ? t("shared.deleteAccount.deleting") : undefined}
onConfirm={submit}
onCancel={busy ? () => {} : onCancel}
>
<p className="text-sm text-(--t-text-secondary)">{t("shared.deleteAccount.deviceNote")}</p>
{subscriptionRenews({ billingEnabled, status, cancelled }) && (
<p data-delete-account-subscription-note className="text-sm text-(--t-text-secondary)">{t("shared.deleteAccount.subscriptionNote")}</p>
)}
{loaded && (
<label className="flex flex-col gap-1.5 text-sm text-(--t-text-secondary)">
<span>{t("shared.deleteAccount.typeToConfirm", { target: deleteConfirmationTarget(email) })}</span>
<input
data-delete-account-confirm-input
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={busy}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
className="form-input w-full px-3 py-2 rounded-lg text-sm outline-hidden bg-(--t-bg-input) border border-(--t-border) text-(--t-text-primary)"
/>
</label>
)}
{error && <p className="text-xs text-(--t-status-error)">{error}</p>}
</ConfirmModal>
);
}
16 changes: 16 additions & 0 deletions src/i18n/locales/cs/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
16 changes: 16 additions & 0 deletions src/i18n/locales/en/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
16 changes: 16 additions & 0 deletions src/i18n/locales/fr/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
16 changes: 16 additions & 0 deletions src/i18n/locales/ru/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "скрыть"
},
Expand Down
16 changes: 16 additions & 0 deletions src/i18n/locales/tr/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
16 changes: 16 additions & 0 deletions src/i18n/locales/zh/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "关闭"
},
Expand Down
Loading
Loading