diff --git a/src/app/dashboard/settings/_components/settings-content.tsx b/src/app/dashboard/settings/_components/settings-content.tsx index 0fc5f08..7af69e6 100644 --- a/src/app/dashboard/settings/_components/settings-content.tsx +++ b/src/app/dashboard/settings/_components/settings-content.tsx @@ -162,8 +162,11 @@ export function SettingsContent({ const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [isLinking, setIsLinking] = useState(false); const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; - const { colorMode, updateAppearance, isUpdating: appearanceUpdating } = - useAppearance(); + const { + colorMode, + updateAppearance, + isUpdating: appearanceUpdating, + } = useAppearance(); const utils = api.useUtils(); const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(); const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({ @@ -221,8 +224,11 @@ export function SettingsContent({ }; // Queries - const { data: profile, refetch: refetchProfile, isFetched: profileFetched } = - api.settings.getProfile.useQuery(); + const { + data: profile, + refetch: refetchProfile, + isFetched: profileFetched, + } = api.settings.getProfile.useQuery(); const isAdmin = profile?.role === "admin"; const { data: dataStats } = api.settings.getDataStats.useQuery(); @@ -285,10 +291,13 @@ export function SettingsContent({ }, }); - const deleteDataMutation = api.settings.deleteAllData.useMutation({ - onSuccess: () => { - toast.success("All data has been permanently deleted"); + const deleteAccountMutation = api.settings.deleteAccount.useMutation({ + onSuccess: async () => { + toast.success("Your account and data have been permanently deleted"); setDeleteConfirmText(""); + await authClient.signOut().catch(() => undefined); + router.replace("/login"); + router.refresh(); }, onError: (error: { message: string }) => { toast.error(`Delete failed: ${error.message}`); @@ -401,12 +410,12 @@ export function SettingsContent({ } }; - const handleDeleteAllData = () => { - if (deleteConfirmText !== "delete all my data") { - toast.error("Please type 'delete all my data' to confirm"); + const handleDeleteAccount = () => { + if (deleteConfirmText !== "DELETE MY ACCOUNT") { + toast.error("Please type 'DELETE MY ACCOUNT' to confirm"); return; } - deleteDataMutation.mutate({ confirmText: deleteConfirmText }); + deleteAccountMutation.mutate({ confirmText: deleteConfirmText }); }; // Set initial name value once when profile loads @@ -706,10 +715,7 @@ export function SettingsContent({ {colorModes.map((modeOption) => ( - + {modeOption.label} ))} @@ -1283,37 +1289,43 @@ export function SettingsContent({ Are you absolutely sure? - This action cannot be undone. This will permanently delete - your account and remove your data from our servers. + This action cannot be undone. It permanently deletes your + account, invoices, clients, businesses, expenses, time + entries, uploaded files, and sign-in data from our servers.
setDeleteConfirmText(e.target.value)} - placeholder="delete all my data" + placeholder="DELETE MY ACCOUNT" />
Cancel - Delete Account + {deleteAccountMutation.isPending + ? "Deleting…" + : "Delete Account"}
diff --git a/src/components/legal/privacy-policy-content.tsx b/src/components/legal/privacy-policy-content.tsx index 1cf4b7e..eef4279 100644 --- a/src/components/legal/privacy-policy-content.tsx +++ b/src/components/legal/privacy-policy-content.tsx @@ -1,7 +1,4 @@ -import { - LEGAL_PRIVACY_EMAIL, - LEGAL_WEBSITE, -} from "~/lib/legal"; +import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal"; import { brand } from "~/lib/branding"; import { LegalDocument, @@ -16,9 +13,9 @@ const sections: LegalSection[] = [ children: ( <> - This Privacy Policy explains how {brand.name} collects, uses, and protects - information when you use our invoicing platform, including the web app and mobile - app (the “Service”). + This Privacy Policy explains how {brand.name} collects, uses, and + protects information when you use our invoicing platform, including + the web app and mobile app (the “Service”). If you have questions about this policy, email us at{" "} @@ -33,19 +30,21 @@ const sections: LegalSection[] = [ children: ( <> - When you create an account and use the Service, you provide information such as - your name, email address, business details, client records, invoice content, and - time entries. This is the data you enter to run your invoicing workflow. + When you create an account and use the Service, you provide + information such as your name, email address, business details, client + records, invoice content, and time entries. This is the data you enter + to run your invoicing workflow. - You may also add payment instructions that appear on invoices, such as bank - transfer details. We do not process card payments on your behalf. + You may also add payment instructions that appear on invoices, such as + bank transfer details. We do not process card payments on your behalf. - We also collect some technical information automatically so the Service stays - secure and reliable. This can include your IP address, device and browser or app - details, log and diagnostic data, and session cookies that keep you signed in. - Some deployments may use optional, privacy-focused analytics. + We also collect some technical information automatically so the + Service stays secure and reliable. This can include your IP address, + device and browser or app details, log and diagnostic data, and + session cookies that keep you signed in. Some deployments may use + optional, privacy-focused analytics. ), @@ -56,9 +55,10 @@ const sections: LegalSection[] = [ children: ( <> - We use your information to provide and operate the Service, authenticate your - account, send transactional messages such as password resets, respond to support - requests, monitor security and performance, and meet legal obligations. + We use your information to provide and operate the Service, + authenticate your account, send transactional messages such as + password resets, respond to support requests, monitor security and + performance, and meet legal obligations. ), @@ -69,24 +69,24 @@ const sections: LegalSection[] = [ children: ( <> - We do not sell your personal information. We share it only when needed to run the - Service or when the law requires it. + We do not sell your personal information. We share it only when needed + to run the Service or when the law requires it. - We work with service providers that host our infrastructure, deliver transactional - email, support single sign-on when enabled on your instance, and optionally provide - privacy-focused analytics. These vendors may process your information only to - perform services for us. + We work with service providers that host our infrastructure, deliver + transactional email, support single sign-on when enabled on your + instance, and optionally provide privacy-focused analytics. These + vendors may process your information only to perform services for us. - We may disclose information if we believe it is reasonably necessary to comply with - law, respond to a valid legal request, or protect the security and integrity of the - Service. + We may disclose information if we believe it is reasonably necessary + to comply with law, respond to a valid legal request, or protect the + security and integrity of the Service. - If we are involved in a merger, acquisition, or sale of assets, your information - may be transferred as part of that transaction, subject to continued protection - consistent with this policy. + If we are involved in a merger, acquisition, or sale of assets, your + information may be transferred as part of that transaction, subject to + continued protection consistent with this policy. ), @@ -97,14 +97,25 @@ const sections: LegalSection[] = [ children: ( <> - We use reasonable safeguards to protect information, including encryption in - transit, access controls, and secure authentication. No method of transmission or - storage is completely secure. + We use reasonable safeguards to protect information, including + encryption in transit, access controls, and secure authentication. No + method of transmission or storage is completely secure. - We retain information for as long as you have an account or as needed to provide - the Service. We may keep certain records longer when required by law or for - legitimate purposes such as fraud prevention or dispute resolution. + We retain information for as long as you have an account or as needed + to provide the Service. We may keep certain records longer when + required by law or for legitimate purposes such as fraud prevention or + dispute resolution. + + + You can permanently delete your account from Settings in the mobile + app or web app. Account deletion removes your account record and + associated Service data, including invoices, clients, businesses, + expenses, time entries, uploaded files, access keys, and active + sessions. The action cannot be undone. Limited information may be + retained only when required by law, and residual copies may remain in + secure backups until those backups are overwritten through our normal + retention cycle. ), @@ -115,13 +126,16 @@ const sections: LegalSection[] = [ children: ( <> - Depending on where you live, you may have the right to access, correct, delete, or - export your personal information, or to object to or restrict certain processing. + Depending on where you live, you may have the right to access, + correct, delete, or export your personal information, or to object to + or restrict certain processing. - To exercise these rights, contact us at{" "} - {LEGAL_PRIVACY_EMAIL}. We will - respond within a reasonable timeframe and as required by applicable law. + You can delete your account directly in Settings. To exercise another + privacy right, or if you cannot access your account, contact us at{" "} + {LEGAL_PRIVACY_EMAIL}. + We will respond within a reasonable timeframe and as required by + applicable law. ), @@ -132,13 +146,13 @@ const sections: LegalSection[] = [ children: ( <> - We use cookies and similar technologies to keep you signed in, remember - preferences such as theme, and, when enabled on a deployment, measure usage with - privacy-focused analytics. + We use cookies and similar technologies to keep you signed in, + remember preferences such as theme, and, when enabled on a deployment, + measure usage with privacy-focused analytics. - You can control cookies through your browser settings. If you disable essential - cookies, some parts of the Service may not work correctly. + You can control cookies through your browser settings. If you disable + essential cookies, some parts of the Service may not work correctly. ), @@ -149,22 +163,26 @@ const sections: LegalSection[] = [ children: ( <> - The Service may link to third-party websites or integrate with services you - configure, such as single sign-on. Those services have their own privacy policies, - and we are not responsible for their practices. + The Service may link to third-party websites or integrate with + services you configure, such as single sign-on. Those services have + their own privacy policies, and we are not responsible for their + practices. - The Service is not intended for children under 13. If you believe a child has - provided us personal information, contact us and we will delete it. + The Service is not intended for children under 13. If you believe a + child has provided us personal information, contact us and we will + delete it. - Your information may be processed in countries other than your own. Where required, - we use appropriate safeguards for international transfers. + Your information may be processed in countries other than your own. + Where required, we use appropriate safeguards for international + transfers. - We may update this policy from time to time. If we make material changes, we will - post the updated policy on the Service and may notify you by email. Continued use - after changes take effect means you accept the updated policy. + We may update this policy from time to time. If we make material + changes, we will post the updated policy on the Service and may notify + you by email. Continued use after changes take effect means you accept + the updated policy. ), @@ -176,7 +194,8 @@ const sections: LegalSection[] = [ <> For privacy questions or requests, email{" "} - {LEGAL_PRIVACY_EMAIL} or visit{" "} + {LEGAL_PRIVACY_EMAIL} or + visit{" "} {LEGAL_WEBSITE.replace(/^https?:\/\//, "")} diff --git a/src/server/api/routers/settings.ts b/src/server/api/routers/settings.ts index c0a612f..f306cd5 100644 --- a/src/server/api/routers/settings.ts +++ b/src/server/api/routers/settings.ts @@ -3,6 +3,7 @@ import { and, count, eq, isNull } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; import bcrypt from "bcryptjs"; import { logAuditEvent } from "~/lib/audit-log"; +import { deleteObject } from "~/lib/object-storage"; import { createTRPCRouter, protectedProcedure, @@ -12,6 +13,8 @@ import { import { requireAdmin } from "~/server/api/require-admin"; import { accounts, + apiKeys, + auditLog, users, clients, businesses, @@ -20,10 +23,14 @@ import { invoicePayments, invoiceTemplates, expenses, + expenseReceipts, recurringInvoices, recurringInvoiceItems, + sessions, + ssoProviders, timeEntries, platformSettings, + verificationTokens, } from "~/server/db/schema"; import { colorModeSchema, @@ -703,25 +710,27 @@ export const settingsRouter = createTRPCRouter({ }, }); - const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({ - where: eq(recurringInvoices.createdById, userId), - with: { - client: { columns: { name: true } }, - business: { columns: { name: true, nickname: true } }, - items: { - columns: { - description: true, - hours: true, - rate: true, - position: true, + const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany( + { + where: eq(recurringInvoices.createdById, userId), + with: { + client: { columns: { name: true } }, + business: { columns: { name: true, nickname: true } }, + items: { + columns: { + description: true, + hours: true, + rate: true, + position: true, + }, + orderBy: (items, { asc }) => [ + asc(items.position), + asc(items.createdAt), + ], }, - orderBy: (items, { asc }) => [ - asc(items.position), - asc(items.createdAt), - ], }, }, - }); + ); const userExpenses = await ctx.db.query.expenses.findMany({ where: eq(expenses.createdById, userId), @@ -1101,7 +1110,9 @@ export const settingsRouter = createTRPCRouter({ ...(input.user.animationSpeedMultiplier !== undefined && { animationSpeedMultiplier: input.user.animationSpeedMultiplier, }), - ...(input.user.theme !== undefined && { theme: input.user.theme }), + ...(input.user.theme !== undefined && { + theme: input.user.theme, + }), ...(input.user.onboardingCompletedAt !== undefined && { onboardingCompletedAt: input.user.onboardingCompletedAt, }), @@ -1137,7 +1148,9 @@ export const settingsRouter = createTRPCRouter({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - error instanceof Error ? error.message : "Import failed unexpectedly", + error instanceof Error + ? error.message + : "Import failed unexpectedly", }); } }), @@ -1171,44 +1184,67 @@ export const settingsRouter = createTRPCRouter({ }; }), - // Delete all user data (for account deletion) - deleteAllData: sessionProcedure + // Permanently delete the signed-in account and all associated data. + deleteAccount: sessionProcedure .input( z.object({ - confirmText: z.string().refine((val) => val === "DELETE ALL DATA", { - message: "You must type 'DELETE ALL DATA' to confirm", + confirmText: z.string().refine((val) => val === "DELETE MY ACCOUNT", { + message: "You must type 'DELETE MY ACCOUNT' to confirm", }), }), ) .mutation(async ({ ctx }) => { const userId = ctx.session.user.id; - return await ctx.db.transaction(async (tx) => { - // Delete in order due to foreign key constraints - // 1. Invoice items (cascade should handle this, but being explicit) - const userInvoiceIds = await tx - .select({ id: invoices.id }) - .from(invoices) - .where(eq(invoices.createdById, userId)); + const [receiptObjects, logoObjects] = await Promise.all([ + ctx.db + .select({ storageKey: expenseReceipts.storageKey }) + .from(expenseReceipts) + .innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id)) + .where(eq(expenses.createdById, userId)), + ctx.db + .select({ storageKey: businesses.logoStorageKey }) + .from(businesses) + .where(eq(businesses.createdById, userId)), + ]); - if (userInvoiceIds.length > 0) { - for (const invoice of userInvoiceIds) { - await tx - .delete(invoiceItems) - .where(eq(invoiceItems.invoiceId, invoice.id)); - } - } + // Delete uploaded personal data before removing its database pointers. If object + // storage is unavailable, the account remains intact so the user can retry. + await Promise.all( + [...receiptObjects, ...logoObjects].flatMap(({ storageKey }) => + storageKey ? [deleteObject(storageKey)] : [], + ), + ); - // 2. Invoices + await ctx.db.transaction(async (tx) => { + // Dependents without cascading foreign keys must be removed first. + await tx.delete(auditLog).where(eq(auditLog.actorUserId, userId)); + await tx + .delete(recurringInvoices) + .where(eq(recurringInvoices.createdById, userId)); + await tx.delete(expenses).where(eq(expenses.createdById, userId)); + await tx + .delete(invoicePayments) + .where(eq(invoicePayments.createdById, userId)); await tx.delete(invoices).where(eq(invoices.createdById, userId)); - - // 3. Clients + await tx.delete(timeEntries).where(eq(timeEntries.createdById, userId)); + await tx + .delete(invoiceTemplates) + .where(eq(invoiceTemplates.createdById, userId)); await tx.delete(clients).where(eq(clients.createdById, userId)); - - // 4. Businesses await tx.delete(businesses).where(eq(businesses.createdById, userId)); - return { success: true }; + // Authentication, access, and verification records. + await tx.delete(apiKeys).where(eq(apiKeys.userId, userId)); + await tx.delete(ssoProviders).where(eq(ssoProviders.userId, userId)); + await tx.delete(accounts).where(eq(accounts.userId, userId)); + await tx.delete(sessions).where(eq(sessions.userId, userId)); + await tx + .delete(verificationTokens) + .where(eq(verificationTokens.identifier, ctx.session.user.email)); + await tx.delete(users).where(eq(users.id, userId)); }); + + return { success: true }; }), });