From a638b9e7c31832b8d0799e5f6191838ec3b9f3a9 Mon Sep 17 00:00:00 2001 From: Ajay Date: Sun, 19 May 2024 20:43:59 -0400 Subject: [PATCH] feat: delete account, remove cron job, misc --- app/actions/deleteAccount.ts | 67 ++++++++ app/api/cron/cleanup/route.ts | 44 ----- {components => app/gallery}/gallery-page.tsx | 4 +- app/gallery/page.tsx | 2 +- app/p/[id]/page.tsx | 3 +- {components => app/p/[id]}/photo-page.tsx | 0 components/home/upload-dialog.tsx | 11 +- components/layout/delete-account-dialog.tsx | 168 +++++++++++++++++++ components/layout/user-dropdown.tsx | 19 ++- components/ui/input.tsx | 16 +- components/ui/label.tsx | 26 +++ lib/supabase/types_db.ts | 6 +- package.json | 2 + pnpm-lock.yaml | 26 +++ vercel.json | 5 +- 15 files changed, 326 insertions(+), 73 deletions(-) create mode 100644 app/actions/deleteAccount.ts delete mode 100644 app/api/cron/cleanup/route.ts rename {components => app/gallery}/gallery-page.tsx (90%) rename {components => app/p/[id]}/photo-page.tsx (100%) create mode 100644 components/layout/delete-account-dialog.tsx create mode 100644 components/ui/label.tsx diff --git a/app/actions/deleteAccount.ts b/app/actions/deleteAccount.ts new file mode 100644 index 0000000..dfa4306 --- /dev/null +++ b/app/actions/deleteAccount.ts @@ -0,0 +1,67 @@ +"use server"; + +import { createAdminClient } from "@/lib/supabase/admin"; +import { createClient } from "@/lib/supabase/server"; +import { cookies } from "next/headers"; +import { z } from "zod"; +import { redirect } from "next/navigation"; + +type FormState = { + message: string; + status: number; +}; + +export async function deleteAccount(prevState: FormState, formData: FormData) { + const cookieStore = cookies(); + const supabase = createClient(cookieStore); + const supabaseAdmin = createAdminClient(); + + // get user object + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + if (userError) + return { + message: `Unable to get user object: ${userError.message}`, + status: 400, + }; + if (!user) return { message: `Unable to get user object`, status: 400 }; + + // delete user input storage items + const { error: storageInputError } = await supabaseAdmin.storage + .from("input") + .remove([`${user.id}`]); + if (storageInputError) + return { + message: `Unable to delete user input images: ${storageInputError.message}`, + status: 400, + }; + + // delete user output storage items + const { error: storageOutputError } = await supabaseAdmin.storage + .from("output") + .remove([`${user.id}`]); + if (storageOutputError) + return { + message: `Unable to delete user output images: ${storageOutputError.message}`, + status: 400, + }; + + // delete user data + const { error } = await supabase.from("data").delete().eq("user_id", user.id); + + // delete user + const { data: deleteData, error: deleteError } = + await supabaseAdmin.auth.admin.deleteUser(user.id); + if (error) + return { + message: `Unable to delete user: ${deleteError?.message}`, + status: 400, + }; + + return { + message: `Successfully deleted account for ${user.email}. Sign out or refresh the page`, + status: 200, + }; +} diff --git a/app/api/cron/cleanup/route.ts b/app/api/cron/cleanup/route.ts deleted file mode 100644 index 4ed7b27..0000000 --- a/app/api/cron/cleanup/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createAdminClient } from "@/lib/supabase/admin"; -import { subDays } from "date-fns"; - -export async function POST(req: Request) { - // Authorize request - const authHeader = req.headers.get("authorization"); - if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { - return new Response("Unauthorized", { - status: 401, - }); - } - - const supabase = createAdminClient(); - - // TODO: Implement pagination if there are more than 1000 rows - // get ids to delete from data - const { data: ids, error: idsError } = await supabase - .from("data") - .select("id") - .lte("created_at", subDays(new Date(), 1).toISOString()); - if (idsError) - return new Response("Unable to get ids from data table", { status: 500 }); - - // delete from storage - const toDelete = ids.map((obj) => obj.id); - const { error: storageError } = await supabase.storage - .from("data") - .remove(toDelete); - if (storageError) - return new Response("Unable to remove data images from storage", { - status: 500, - }); - - // Prob want to keep this data for count - // delete from table - // const { count, error } = await supabase - // .from("data") - // .delete() - // .lte("created_at", subDays(new Date(), 1).toISOString()); - // if (error) - // return new Response(`An error occured: ${error.message}`, { status: 500 }); - - return new Response(`${toDelete.length} images cleaned up`, { status: 200 }); -} diff --git a/components/gallery-page.tsx b/app/gallery/gallery-page.tsx similarity index 90% rename from components/gallery-page.tsx rename to app/gallery/gallery-page.tsx index 96a5829..45e9075 100644 --- a/components/gallery-page.tsx +++ b/app/gallery/gallery-page.tsx @@ -2,10 +2,10 @@ import Balancer from "react-wrap-balancer"; import PhotoBooth from "@/components/home/photo-booth"; -import { Tables } from "@/lib/supabase/types_db"; import { useRouter } from "next/navigation"; +import { DataProps } from "@/lib/types"; -export function GalleryPage({ data }: { data: Tables<"data">[] | null }) { +export function GalleryPage({ data }: { data: DataProps[] | null }) { const router = useRouter(); return (
diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index 061520a..01f5eb9 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -1,6 +1,6 @@ import { createClient } from "@/lib/supabase/server"; import { cookies } from "next/headers"; -import { GalleryPage } from "@/components/gallery-page"; +import { GalleryPage } from "@/app/gallery/gallery-page"; export default async function Gallery() { const cookieStore = cookies(); diff --git a/app/p/[id]/page.tsx b/app/p/[id]/page.tsx index a9b8e27..3bac2bd 100644 --- a/app/p/[id]/page.tsx +++ b/app/p/[id]/page.tsx @@ -1,5 +1,4 @@ -import { getPlaiceholder } from "plaiceholder"; -import PhotoPage from "@/components/photo-page"; +import PhotoPage from "@/app/p/[id]/photo-page"; import { createClient } from "@/lib/supabase/server"; import { cookies } from "next/headers"; import { notFound } from "next/navigation"; diff --git a/components/photo-page.tsx b/app/p/[id]/photo-page.tsx similarity index 100% rename from components/photo-page.tsx rename to app/p/[id]/photo-page.tsx diff --git a/components/home/upload-dialog.tsx b/components/home/upload-dialog.tsx index 6b715d5..232d044 100644 --- a/components/home/upload-dialog.tsx +++ b/components/home/upload-dialog.tsx @@ -21,13 +21,7 @@ import { Button } from "@/components/ui/button"; import { create } from "zustand"; import Image from "next/image"; import { Separator } from "@/components/ui/separator"; -import { - ChangeEvent, - useCallback, - useMemo, - useState, - useTransition, -} from "react"; +import { ChangeEvent, useCallback, useMemo, useState } from "react"; import { LoadingDots } from "@/components/shared/icons"; import { UploadCloud } from "lucide-react"; import { useFormState, useFormStatus } from "react-dom"; @@ -155,7 +149,7 @@ export function UploadForm() { return (
@@ -248,6 +242,7 @@ export function UploadForm() { onChange={onChangePicture} />
+ {state?.message &&

{state.message}

}
diff --git a/components/layout/delete-account-dialog.tsx b/components/layout/delete-account-dialog.tsx new file mode 100644 index 0000000..feaded7 --- /dev/null +++ b/components/layout/delete-account-dialog.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { useMediaQuery } from "@/lib/hooks/use-media-query"; +import { Button } from "@/components/ui/button"; +import { create } from "zustand"; +import Image from "next/image"; +import { Separator } from "@/components/ui/separator"; +import { useEffect } from "react"; +import { LoadingDots } from "@/components/shared/icons"; +import { deleteAccount } from "@/app/actions/deleteAccount"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useFormState, useFormStatus } from "react-dom"; + +type DeleteDialogStore = { + open: boolean; + setOpen: (isOpen: boolean) => void; +}; + +export const useDeleteAccountDialog = create((set) => ({ + open: false, + setOpen: (open) => set(() => ({ open: open })), +})); + +export function DeleteAccountDialog() { + const [open, setOpen] = useDeleteAccountDialog((s) => [s.open, s.setOpen]); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + if (isDesktop) { + return ( + + + + + Logo + + + Delete Account + + + This account will be deleted along with all your uploaded images + and AI generated images/gifs. + + + + + + {/* Buttons */} +
+ +
+
+
+ ); + } + + return ( + + + + + Logo + + + Delete Account + + + This account will be deleted along with all your uploaded images and + AI generated images/gifs. + + + + + + {/* Buttons */} +
+ +
+ + + + + + +
+
+ ); +} + +function DeleteAccountForm() { + const [state, deleteAccountFormAction] = useFormState(deleteAccount, { + message: "", + status: 0, + }); + + useEffect(() => { + if (state.message.includes("Successfully deleted account for")) { + window.location.reload(); + } + }, [state]); + + return ( + +
+ + + {state?.message &&

{state.message}

} +
+ + + ); +} + +function DeleteAccountButton() { + const { pending } = useFormStatus(); + return ( + + ); +} diff --git a/components/layout/user-dropdown.tsx b/components/layout/user-dropdown.tsx index 6f0cf89..9847f41 100644 --- a/components/layout/user-dropdown.tsx +++ b/components/layout/user-dropdown.tsx @@ -11,13 +11,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Coins, CreditCard, LogOut, Receipt } from "lucide-react"; +import { Coins, CreditCard, LogOut, Receipt, Trash2 } from "lucide-react"; import { billing } from "@/app/actions/billing"; import { LoadingDots } from "@/components/shared/icons"; import { CheckoutDialog, useCheckoutDialog, } from "@/components/layout/checkout-dialog"; +import { + DeleteAccountDialog, + useDeleteAccountDialog, +} from "@/components/layout/delete-account-dialog"; export function UserDropdown({ userData }: { userData: UserData | null }) { const supabase = createClient(); @@ -35,6 +39,8 @@ export function UserDropdown({ userData }: { userData: UserData | null }) { credits: number; }>(); + const setShowDeleteAccountModal = useDeleteAccountDialog((s) => s.setOpen); + useEffect(() => { // TODO: display stripe success or failure modal const stripeSuccess = searchParams.get("success") === "true"; @@ -58,6 +64,7 @@ export function UserDropdown({ userData }: { userData: UserData | null }) { return ( <> + @@ -137,6 +144,16 @@ export function UserDropdown({ userData }: { userData: UserData | null }) {

Logout

+ + + + setShowDeleteAccountModal(true)} + > + +

Delete Account

+
diff --git a/components/ui/input.tsx b/components/ui/input.tsx index 677d05f..9d631e7 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; export interface InputProps extends React.InputHTMLAttributes {} @@ -12,14 +12,14 @@ const Input = React.forwardRef( type={type} className={cn( "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} ref={ref} {...props} /> - ) - } -) -Input.displayName = "Input" + ); + }, +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/lib/supabase/types_db.ts b/lib/supabase/types_db.ts index 083b593..ea935c6 100644 --- a/lib/supabase/types_db.ts +++ b/lib/supabase/types_db.ts @@ -16,7 +16,7 @@ export type Database = { id: string input: string output: string | null - user_id: string + user_id: string | null } Insert: { created_at?: string @@ -24,7 +24,7 @@ export type Database = { id: string input?: string output?: string | null - user_id?: string + user_id?: string | null } Update: { created_at?: string @@ -32,7 +32,7 @@ export type Database = { id?: string input?: string output?: string | null - user_id?: string + user_id?: string | null } Relationships: [ { diff --git a/package.json b/package.json index f7d508f..54c1aa3 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "format": "prettier \"**/*.{css,js,json,jsx,ts,tsx}\"", "tunnel": "cloudflared tunnel --url http://localhost:3000", "gen-types": "pnpm dlx supabase gen types typescript --linked -s public > lib/supabase/types_db.ts", + "gen-types-2": "pnpm dlx supabase gen types typescript --project-id zufrwdcmaojotovkjeww --schema public > lib/supabase/types_db.ts", "gen-schema": "pnpm dlx supabase db dump --linked -f supabase/schema.sql", "fixtures:webhook": "stripe fixtures stripe/webhook.json", "fixtures:products": "stripe fixtures stripe/products.json" @@ -19,6 +20,7 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 059299a..148c448 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 version: 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.0.2 + version: 2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -597,6 +600,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.0.2': + resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.0.6': resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==} peerDependencies: @@ -3351,6 +3367,16 @@ snapshots: optionalDependencies: '@types/react': 18.3.1 + '@radix-ui/react-label@2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.5 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.1 + '@types/react-dom': 18.3.0 + '@radix-ui/react-menu@2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.24.5 diff --git a/vercel.json b/vercel.json index 1f9ce0d..0e0dcd2 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,3 @@ { - "crons": [{ - "path": "/api/cron/cleanup", - "schedule": "0 0 * * *" - }] + } \ No newline at end of file