Просмотр исходного кода

option for user to delete his account

Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
f8ad898dbc

+ 98 - 1
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -17,12 +17,16 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
-import { Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, User } from 'lucide-react'
+import { AlertTriangle, Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
 import { QRCodeSVG } from 'qrcode.react'
 import { updateEmail } from '@/features/settings/Actions/accountActions'
+import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
+import { signOut } from '@/lib/auth-client'
+import { useRouter } from 'next/navigation'
 
 export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: { twoFactorEnabled: boolean }) {
   const { data: session } = useSession()
+  const router = useRouter()
   const [name, setName] = useState('')
   const [email, setEmail] = useState('')
 
@@ -51,6 +55,29 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
   const [verifying2FA, setVerifying2FA] = useState(false)
   const [copiedBackup, setCopiedBackup] = useState(false)
 
+  // Delete account state
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+  const [deleteConfirmText, setDeleteConfirmText] = useState('')
+  const [deleting, setDeleting] = useState(false)
+
+  const handleDeleteAccount = async () => {
+    if (deleteConfirmText !== 'delete me') return
+    setDeleting(true)
+    try {
+      const result = await deleteAccount()
+      if (result.success) {
+        await signOut()
+        router.push('/auth/sign-in')
+      } else {
+        toast.error(result.error || 'Failed to delete account')
+        setDeleting(false)
+      }
+    } catch {
+      toast.error('Failed to delete account')
+      setDeleting(false)
+    }
+  }
+
   const handleEnable2FA = async () => {
     if (!twoFactorPassword) {
       toast.error('Please enter your password')
@@ -512,6 +539,76 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
           )}
         </DialogContent>
       </Dialog>
+
+      {/* Danger Zone */}
+      <Card className="border-destructive/30 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <AlertTriangle className="h-5 w-5 text-destructive" />
+          <CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="flex items-start justify-between gap-4">
+            <div>
+              <p className="font-medium">Delete Account</p>
+              <p className="text-sm text-muted-foreground">
+                Permanently delete your account and all associated data including vehicles, service records,
+                work orders, invoices, customers, files, and payments. This action cannot be undone.
+              </p>
+            </div>
+            <Button
+              variant="destructive"
+              onClick={() => { setDeleteConfirmText(''); setDeleteDialogOpen(true) }}
+            >
+              <Trash2 className="mr-2 h-4 w-4" />
+              Delete Account
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Delete Account Confirmation Dialog */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="text-destructive">Delete Account</DialogTitle>
+            <DialogDescription>
+              This will permanently delete your account and all data associated with it. This action is
+              irreversible. All your vehicles, service records, work orders, quotes, customers, files,
+              and payment history will be permanently removed.
+            </DialogDescription>
+          </DialogHeader>
+          <div className="space-y-3 py-2">
+            <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
+              <p className="text-sm font-medium text-destructive">
+                Type <span className="font-mono font-bold">delete me</span> to confirm
+              </p>
+            </div>
+            <Input
+              value={deleteConfirmText}
+              onChange={(e) => setDeleteConfirmText(e.target.value)}
+              placeholder="delete me"
+              autoComplete="off"
+            />
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}>
+              Cancel
+            </Button>
+            <Button
+              variant="destructive"
+              onClick={handleDeleteAccount}
+              disabled={deleteConfirmText !== 'delete me' || deleting}
+            >
+              {deleting ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Trash2 className="mr-2 h-4 w-4" />
+              )}
+              {deleting ? 'Deleting...' : 'Permanently Delete'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 1 - 1
src/app/api/quotes/[id]/pdf/route.ts

@@ -86,7 +86,7 @@ export async function GET(
 
     const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
 
-    return new NextResponse(new Uint8Array(buffer), {
+    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${quoteNum}.pdf"`,

+ 4 - 4
src/app/api/services/[id]/pdf/route.ts

@@ -196,7 +196,7 @@ export async function GET(
     const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
 
     // Merge attached PDF diagnostic reports into the invoice
-    let finalBytes: Uint8Array;
+    let finalBuffer: ArrayBuffer;
     if (pdfAttachments.length > 0) {
       const mergedPdf = await PDFDocument.load(invoiceBuffer);
       for (const att of pdfAttachments) {
@@ -211,12 +211,12 @@ export async function GET(
         }
       }
       const saved = await mergedPdf.save();
-      finalBytes = new Uint8Array(saved);
+      finalBuffer = saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer;
     } else {
-      finalBytes = new Uint8Array(invoiceBuffer);
+      finalBuffer = invoiceBuffer.buffer.slice(invoiceBuffer.byteOffset, invoiceBuffer.byteOffset + invoiceBuffer.byteLength) as ArrayBuffer;
     }
 
-    return new NextResponse(finalBytes, {
+    return new NextResponse(finalBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${invoiceNum}.pdf"`,

+ 1 - 1
src/app/api/share/invoice/[orgId]/[token]/pdf/route.ts

@@ -147,7 +147,7 @@ export async function GET(
 
     const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
 
-    return new NextResponse(new Uint8Array(buffer), {
+    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${invoiceNum}.pdf"`,

+ 137 - 0
src/features/settings/Actions/deleteAccount.ts

@@ -0,0 +1,137 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { unlink, rm } from "fs/promises";
+import path from "path";
+
+export async function deleteAccount() {
+  return withAuth(async ({ userId, organizationId }) => {
+    // Count other members in the organization
+    const memberCount = organizationId
+      ? await db.organizationMember.count({
+          where: { organizationId },
+        })
+      : 0;
+
+    const isLastMember = memberCount <= 1;
+
+    if (isLastMember && organizationId) {
+      // --- LAST MEMBER: delete everything ---
+
+      // Collect file paths to clean up from disk
+      const filePaths: string[] = [];
+
+      const attachments = await db.serviceAttachment.findMany({
+        where: { serviceRecord: { vehicle: { organizationId } } },
+        select: { fileUrl: true },
+      });
+      for (const att of attachments) {
+        filePaths.push(resolveUploadPath(att.fileUrl));
+      }
+
+      const inventoryParts = await db.inventoryPart.findMany({
+        where: { organizationId },
+        select: { imageUrl: true },
+      });
+      for (const part of inventoryParts) {
+        if (part.imageUrl) filePaths.push(resolveUploadPath(part.imageUrl));
+      }
+
+      const vehicles = await db.vehicle.findMany({
+        where: { organizationId },
+        select: { imageUrl: true },
+      });
+      for (const v of vehicles) {
+        if (v.imageUrl) filePaths.push(resolveUploadPath(v.imageUrl));
+      }
+
+      // Delete membership first (not auto-cascaded from user deletion)
+      await db.organizationMember.deleteMany({
+        where: { userId },
+      });
+
+      // Delete the organization — cascades all org data (vehicles, customers,
+      // quotes, inventory, custom fields, settings, roles, invitations, subscription)
+      await db.organization.delete({
+        where: { id: organizationId },
+      });
+
+      // Delete the user — cascades sessions, accounts, 2FA
+      await db.user.delete({
+        where: { id: userId },
+      });
+
+      // Clean up files from disk (best effort)
+      for (const filePath of filePaths) {
+        try {
+          await unlink(filePath);
+        } catch {
+          // File may already be missing
+        }
+      }
+
+      // Try to remove the org upload directory
+      try {
+        const orgUploadDir = path.join(process.cwd(), "data", "uploads", organizationId);
+        await rm(orgUploadDir, { recursive: true, force: true });
+      } catch {
+        // Directory may not exist
+      }
+    } else {
+      // --- NOT LAST MEMBER: reassign org data, then delete user ---
+
+      // Find another member to reassign data to
+      const otherMember = await db.organizationMember.findFirst({
+        where: { organizationId, NOT: { userId } },
+        select: { userId: true },
+      });
+
+      if (otherMember) {
+        const newOwnerId = otherMember.userId;
+
+        // Reassign all org data owned by this user to another member
+        await db.$transaction([
+          db.vehicle.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.customer.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.quote.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.inventoryPart.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.customFieldDefinition.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.appSetting.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+        ]);
+      }
+
+      // Remove membership
+      await db.organizationMember.deleteMany({
+        where: { userId },
+      });
+
+      // Delete the user — cascades only user-specific data
+      // (sessions, accounts, 2FA; org data was reassigned above)
+      await db.user.delete({
+        where: { id: userId },
+      });
+    }
+
+    return { deleted: true };
+  });
+}