From 10ce404a1d3b9b7338738b744679911f2179440d Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Thu, 27 Feb 2025 19:58:21 +0700 Subject: [PATCH] add trigger for auth.users to custom public.user --- .vscode/extensions.json | 4 +- .vscode/settings.json | 12 +- sigap-website/app/_components/ui/calendar.tsx | 76 +++ sigap-website/app/_components/ui/tabs.tsx | 55 ++ .../(admin-pages)/_components/app-sidebar.tsx | 2 +- .../_components/users/add-user.tsx | 235 +++++++ .../_components/users/column.tsx | 232 +++++++ .../_components/users/edit-user.tsx | 583 +++++++++++++----- .../_components/users/users-table.tsx | 20 +- sigap-website/package-lock.json | 60 +- sigap-website/package.json | 4 +- .../{ => prisma}/data/crime-category.ts | 0 sigap-website/{ => prisma}/data/nav.ts | 0 sigap-website/prisma/data/users.tsx | 0 .../migration.sql | 43 -- .../migration.sql | 18 - .../migration.sql | 106 ---- .../migration.sql | 108 ---- .../migration.sql | 63 -- .../migration.sql | 87 ++- .../migration.sql | 64 ++ .../migration.sql | 40 ++ sigap-website/prisma/schema.prisma | 60 +- .../src/entities/models/users.model.ts | 8 + supabase/config.toml | 7 +- supabase/functions/send-email/.npmrc | 3 - supabase/functions/send-email/deno.json | 3 - supabase/functions/send-email/index.ts | 62 -- .../20250227085404_remote_schema.sql | 212 +++++++ .../20250227091031_remote_schema.sql | 189 ++++++ ...20250227091713_user_management_starter.sql | 55 ++ ...20250227092633_user_management_starter.sql | 0 32 files changed, 1791 insertions(+), 620 deletions(-) create mode 100644 sigap-website/app/_components/ui/calendar.tsx create mode 100644 sigap-website/app/_components/ui/tabs.tsx create mode 100644 sigap-website/app/protected/(admin-pages)/_components/users/add-user.tsx create mode 100644 sigap-website/app/protected/(admin-pages)/_components/users/column.tsx rename sigap-website/{ => prisma}/data/crime-category.ts (100%) rename sigap-website/{ => prisma}/data/nav.ts (100%) create mode 100644 sigap-website/prisma/data/users.tsx delete mode 100644 sigap-website/prisma/migrations/20250217140338_first_prisma_migration/migration.sql delete mode 100644 sigap-website/prisma/migrations/20250218182609_resolve_grant_issue/migration.sql delete mode 100644 sigap-website/prisma/migrations/20250220132229_rename_field_to_snake_case/migration.sql delete mode 100644 sigap-website/prisma/migrations/20250220134948_set_id_field_to_uuid_and_use_varchar_and_text_to_specific_field/migration.sql delete mode 100644 sigap-website/prisma/migrations/20250220212621_add_nav_sub_sub_menu/migration.sql rename sigap-website/prisma/migrations/{20250220204901_add_map_model_and_set_field_to_camel_case_but_snake_case_for_db => 20250227114657_}/migration.sql (73%) create mode 100644 sigap-website/prisma/migrations/20250227123948_sync_custom_users_public_to_auth_user/migration.sql create mode 100644 sigap-website/prisma/migrations/20250227125002_sync_custom_users_public_to_auth_user/migration.sql delete mode 100644 supabase/functions/send-email/.npmrc delete mode 100644 supabase/functions/send-email/deno.json delete mode 100644 supabase/functions/send-email/index.ts create mode 100644 supabase/migrations/20250227085404_remote_schema.sql create mode 100644 supabase/migrations/20250227091031_remote_schema.sql create mode 100644 supabase/migrations/20250227091713_user_management_starter.sql create mode 100644 supabase/migrations/20250227092633_user_management_starter.sql diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 74baffc..09cf720 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,5 @@ { - "recommendations": ["denoland.vscode-deno"] + "recommendations": [ + "denoland.vscode-deno" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 0dccffe..35b884c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { - "deno.enablePaths": ["supabase/functions"], + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "deno.enablePaths": [ + "supabase/functions" + ], "deno.lint": true, "deno.unstable": [ "bare-node-builtins", @@ -15,8 +20,5 @@ "fs", "http", "net" - ], - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + ] } diff --git a/sigap-website/app/_components/ui/calendar.tsx b/sigap-website/app/_components/ui/calendar.tsx new file mode 100644 index 0000000..84bf133 --- /dev/null +++ b/sigap-website/app/_components/ui/calendar.tsx @@ -0,0 +1,76 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/app/_components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/sigap-website/app/_components/ui/tabs.tsx b/sigap-website/app/_components/ui/tabs.tsx new file mode 100644 index 0000000..0f4caeb --- /dev/null +++ b/sigap-website/app/_components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/sigap-website/app/protected/(admin-pages)/_components/app-sidebar.tsx b/sigap-website/app/protected/(admin-pages)/_components/app-sidebar.tsx index 5970ce8..ed8fd76 100644 --- a/sigap-website/app/protected/(admin-pages)/_components/app-sidebar.tsx +++ b/sigap-website/app/protected/(admin-pages)/_components/app-sidebar.tsx @@ -15,7 +15,7 @@ import { SidebarRail, } from "@/app/_components/ui/sidebar"; import { NavPreMain } from "./navigations/nav-pre-main"; -import { navData } from "@/data/nav"; +import { navData } from "@/prisma/data/nav"; export function AppSidebar({ ...props }: React.ComponentProps) { return ( diff --git a/sigap-website/app/protected/(admin-pages)/_components/users/add-user.tsx b/sigap-website/app/protected/(admin-pages)/_components/users/add-user.tsx new file mode 100644 index 0000000..a8ea8fd --- /dev/null +++ b/sigap-website/app/protected/(admin-pages)/_components/users/add-user.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/app/_components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/app/_components/ui/sheet"; +import { Input } from "@/app/_components/ui/input"; +import { Label } from "@/app/_components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/_components/ui/select"; +import { User } from "./users-table"; + +interface AddUserSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: ( + userData: Omit & { password: string } + ) => void; +} + +export function AddUserSheet({ + open, + onOpenChange, + onSave, +}: AddUserSheetProps) { + const [userData, setUserData] = useState< + Omit & { password: string } + >({ + email: "", + firstName: "", + lastName: "", + avatar: "/placeholder.svg?height=40&width=40", + role: "user", + status: "active", + password: "", + }); + + const [errors, setErrors] = useState>({}); + + const validateForm = () => { + const newErrors: Record = {}; + + if (!userData.email) { + newErrors.email = "Email is required"; + } else if (!/\S+@\S+\.\S+/.test(userData.email)) { + newErrors.email = "Email is invalid"; + } + + if (!userData.password) { + newErrors.password = "Password is required"; + } else if (userData.password.length < 6) { + newErrors.password = "Password must be at least 6 characters"; + } + + if (!userData.firstName) { + newErrors.firstName = "First name is required"; + } + + if (!userData.lastName) { + newErrors.lastName = "Last name is required"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + if (validateForm()) { + onSave(userData); + // Reset form + setUserData({ + email: "", + firstName: "", + lastName: "", + avatar: "/placeholder.svg?height=40&width=40", + role: "user", + status: "active", + password: "", + }); + setErrors({}); + } + }; + + const handleInputChange = (field: keyof typeof userData, value: string) => { + setUserData((prev) => ({ ...prev, [field]: value })); + // Clear error when field is edited + if (errors[field]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + return ( + + + + Add New User + + Create a new user account with Supabase authentication. + + +
+
+ + handleInputChange("email", e.target.value)} + className="col-span-3" + /> + {errors.email && ( +

+ {errors.email} +

+ )} +
+ +
+ + handleInputChange("password", e.target.value)} + className="col-span-3" + /> + {errors.password && ( +

+ {errors.password} +

+ )} +
+ +
+ + handleInputChange("firstName", e.target.value)} + className="col-span-3" + /> + {errors.firstName && ( +

+ {errors.firstName} +

+ )} +
+ +
+ + handleInputChange("lastName", e.target.value)} + className="col-span-3" + /> + {errors.lastName && ( +

+ {errors.lastName} +

+ )} +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+ ); +} diff --git a/sigap-website/app/protected/(admin-pages)/_components/users/column.tsx b/sigap-website/app/protected/(admin-pages)/_components/users/column.tsx new file mode 100644 index 0000000..99e2c8d --- /dev/null +++ b/sigap-website/app/protected/(admin-pages)/_components/users/column.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useMemo } from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, Filter, MoreVertical } from "lucide-react"; + +import { Button } from "@/app/_components/ui/button"; +import { Checkbox } from "@/app/_components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/app/_components/ui/dropdown-menu"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/app/_components/ui/avatar"; + +import { User } from "@/types/user"; + +interface ColumnOptions { + openEditSheet: (user: User) => void; + handleRoleFilter: (role: string) => void; + handleStatusFilter: (status: string) => void; +} + +export const useColumns = ({ + openEditSheet, + handleRoleFilter, + handleStatusFilter, +}: ColumnOptions) => { + // Use useMemo to prevent unnecessary re-creation of columns array + const columns = useMemo[]>( + () => [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "usersColoumn", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const user = row.original; + return ( +
+ + + + {user.firstName[0]} + {user.lastName[0]} + + +
+
+ {user.firstName} {user.lastName} +
+
+ {user.email} +
+
+
+ ); + }, + filterFn: (row, id, filterValue) => { + const searchTerm = filterValue.toLowerCase(); + const user = row.original; + return ( + user.firstName.toLowerCase().includes(searchTerm) || + user.lastName.toLowerCase().includes(searchTerm) || + user.email.toLowerCase().includes(searchTerm) + ); + }, + }, + { + accessorKey: "role", + header: ({ column }) => { + return ( +
+ Role + + + + + + Filter by Role + + handleRoleFilter("all")}> + All + + handleRoleFilter("admin")}> + Admin + + handleRoleFilter("staff")}> + Staff + + handleRoleFilter("user")}> + User + + + +
+ ); + }, + }, + { + accessorKey: "status", + header: ({ column }) => { + return ( +
+ Status + + + + + + Filter by Status + + handleStatusFilter("all")}> + All + + handleStatusFilter("active")} + > + Active + + handleStatusFilter("inactive")} + > + Inactive + + + +
+ ); + }, + cell: ({ row }) => { + const status = row.getValue("status") as string; + return ( +
+ {status} +
+ ); + }, + }, + { + accessorKey: "lastSignedIn", + header: "Last Sign In", + }, + { + id: "actions", + cell: ({ row }) => { + const user = row.original; + + return ( + + + + + + Actions + navigator.clipboard.writeText(user.id)} + > + Copy user ID + + + openEditSheet(user)}> + Edit user + + + Delete user + + + + ); + }, + }, + ], + [openEditSheet, handleRoleFilter, handleStatusFilter] + ); + + return columns; +}; diff --git a/sigap-website/app/protected/(admin-pages)/_components/users/edit-user.tsx b/sigap-website/app/protected/(admin-pages)/_components/users/edit-user.tsx index 4d32464..566ba38 100644 --- a/sigap-website/app/protected/(admin-pages)/_components/users/edit-user.tsx +++ b/sigap-website/app/protected/(admin-pages)/_components/users/edit-user.tsx @@ -1,14 +1,32 @@ "use client"; -import { Button } from "@/app/_components/ui/button"; + +import { useState, useEffect } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { CalendarIcon, X } from "lucide-react"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, + SheetClose, +} from "@/app/_components/ui/sheet"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/app/_components/ui/form"; import { Input } from "@/app/_components/ui/input"; +import { Button } from "@/app/_components/ui/button"; import { Select, SelectContent, @@ -16,175 +34,448 @@ import { SelectTrigger, SelectValue, } from "@/app/_components/ui/select"; +import { Textarea } from "@/app/_components/ui/textarea"; import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/app/_components/ui/sheet"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; + Avatar, + AvatarFallback, + AvatarImage, +} from "@/app/_components/ui/avatar"; +import { Calendar } from "@/app/_components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/_components/ui/popover"; +import { Separator } from "@/app/_components/ui/separator"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/app/_components/ui/tabs"; +import { User } from "./users-table"; -import type { User } from "./users-table"; -import { useCallback } from "react"; - -const formSchema = z.object({ - firstName: z.string().min(2, { - message: "First name must be at least 2 characters.", - }), - lastName: z.string().min(2, { - message: "Last name must be at least 2 characters.", - }), - email: z.string().email({ - message: "Please enter a valid email address.", - }), +// Create a schema for form validation +const userFormSchema = z.object({ + // User fields + email: z.string().email({ message: "Please enter a valid email address" }), + firstName: z.string().min(1, { message: "First name is required" }), + lastName: z.string().min(1, { message: "Last name is required" }), + avatar: z.string().optional(), role: z.enum(["admin", "staff", "user"]), - status: z.enum(["active", "inactive"]), + password: z + .string() + .min(8, { message: "Password must be at least 8 characters long" }) + .optional(), + + // Profile fields + bio: z.string().optional(), + phone: z.string().optional(), + address: z.string().optional(), + city: z.string().optional(), + country: z.string().optional(), + birthDate: z.date().optional(), }); +type UserFormValues = z.infer; + +// Extended user type to include profile information +type UserWithProfile = User & { + profile?: { + bio?: string; + phone?: string; + address?: string; + city?: string; + country?: string; + birthDate?: Date; + }; +}; + interface EditUserSheetProps { - isOpen: boolean; + open: boolean; onOpenChange: (open: boolean) => void; - selectedUser: User | null; - onUserUpdate: (updatedUser: User) => void; + user?: UserWithProfile | null; + onSave: (user: UserWithProfile) => void; + isNew?: boolean; } export function EditUserSheet({ - isOpen, + open, onOpenChange, - selectedUser, - onUserUpdate, + user, + onSave, + isNew = false, }: EditUserSheetProps) { - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm({ + resolver: zodResolver(userFormSchema), defaultValues: { - firstName: selectedUser?.firstName || "", - lastName: selectedUser?.lastName || "", - email: selectedUser?.email || "", - role: selectedUser?.role || "user", - status: selectedUser?.status || "inactive", + email: "", + firstName: "", + lastName: "", + avatar: "/placeholder.svg?height=40&width=40", + role: "user", + password: isNew ? "" : undefined, + bio: "", + phone: "", + address: "", + city: "", + country: "", + birthDate: undefined, }, }); - const onSubmit = useCallback( - (values: z.infer) => { - if (!selectedUser) return; + // Update form when user changes + useEffect(() => { + if (user) { + const birthDate = user.profile?.birthDate + ? new Date(user.profile.birthDate) + : undefined; - const updatedUser: User = { - ...selectedUser, - firstName: values.firstName, - lastName: values.lastName, - email: values.email, - role: values.role, - status: values.status, - }; + form.reset({ + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + avatar: user.avatar, + role: user.role, + // Don't populate password for existing users + bio: user.profile?.bio || "", + phone: user.profile?.phone || "", + address: user.profile?.address || "", + city: user.profile?.city || "", + country: user.profile?.country || "", + birthDate: birthDate, + }); + } else if (isNew) { + form.reset({ + email: "", + firstName: "", + lastName: "", + avatar: "/placeholder.svg?height=40&width=40", + role: "user", + password: "", + bio: "", + phone: "", + address: "", + city: "", + country: "", + birthDate: undefined, + }); + } + }, [user, isNew, form]); - onUserUpdate(updatedUser); - onOpenChange(false); - }, - [selectedUser, onUserUpdate, onOpenChange] - ); + const onSubmit = (data: UserFormValues) => { + // Prepare the user object with profile + const updatedUser: UserWithProfile = { + id: user?.id || "new-id", // In a real app, this would be handled by the backend + email: data.email, + firstName: data.firstName || "", + lastName: data.lastName || "", + avatar: data.avatar || "/placeholder.svg?height=40&width=40", + role: data.role, + status: user?.status || "active", + lastSignedIn: + user?.lastSignedIn || new Date().toISOString().split("T")[0], + profile: { + bio: data.bio, + phone: data.phone, + address: data.address, + city: data.city, + country: data.country, + birthDate: data.birthDate, + }, + }; + + // Save the user + onSave(updatedUser); + + // Close the sheet + onOpenChange(false); + }; return ( - - + + - Edit User + {isNew ? "Add New User" : "Edit User"} - Make changes to the user profile here. Click save when you're done. + {isNew + ? "Fill in the details to create a new user account." + : "Make changes to the user profile here."} -
- - ( - - First name - - - - - - )} - /> - ( - - Last name - - - - - - )} - /> - ( - - Email - - - - - - )} - /> - ( - - Role - - - - )} - /> - ( - - Status - - - - )} - /> - - - +
+ + + User Details + Profile Information + +
+ + +
+ + + + {form.watch("firstName")?.charAt(0) || ""} + {form.watch("lastName")?.charAt(0) || ""} + + +
+ +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ + ( + + Email + + + + + + )} + /> + + {isNew && ( + ( + + Password + + + + + Minimum 8 characters + + + + )} + /> + )} + + ( + + Role + + + + )} + /> + + ( + + Avatar URL + + + + + + )} + /> +
+ + + ( + + Bio + +