refactor createUsersTable
This commit is contained in:
parent
2faf6ce83e
commit
9380c371f8
|
@ -39,9 +39,10 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
||||||
const lastName = user?.profile?.last_name || "";
|
const lastName = user?.profile?.last_name || "";
|
||||||
const userEmail = user?.email || "";
|
const userEmail = user?.email || "";
|
||||||
const userAvatar = user?.profile?.avatar || "";
|
const userAvatar = user?.profile?.avatar || "";
|
||||||
|
const username = user?.profile?.username || "";
|
||||||
|
|
||||||
const getFullName = () => {
|
const getFullName = () => {
|
||||||
return `${firstName} ${lastName}`.trim() || "User";
|
return `${firstName} ${lastName}`.trim() || username || "User";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate initials for avatar fallback
|
// Generate initials for avatar fallback
|
||||||
|
@ -58,12 +59,6 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
||||||
return "U";
|
return "U";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle dialog close after successful profile update
|
|
||||||
const handleProfileUpdateSuccess = () => {
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
// You might want to refresh the user data here
|
|
||||||
};
|
|
||||||
|
|
||||||
const { handleSignOut, isPending, errors, error } = useSignOutHandler();
|
const { handleSignOut, isPending, errors, error } = useSignOutHandler();
|
||||||
|
|
||||||
function LogoutButton({ handleSignOut, isPending }: { handleSignOut: () => void; isPending: boolean }) {
|
function LogoutButton({ handleSignOut, isPending }: { handleSignOut: () => void; isPending: boolean }) {
|
||||||
|
@ -99,7 +94,6 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleSignOut();
|
handleSignOut();
|
||||||
|
|
||||||
// Tutup dialog setelah tombol Log out diklik
|
|
||||||
if (!isPending) {
|
if (!isPending) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
@ -133,13 +127,13 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={userAvatar || ""} alt={getFullName()} />
|
<AvatarImage src={userAvatar || ""} alt={username} />
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{getFullName()}</span>
|
<span className="truncate font-semibold">{username}</span>
|
||||||
<span className="truncate text-xs">{userEmail}</span>
|
<span className="truncate text-xs">{userEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
|
@ -154,14 +148,14 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={userAvatar || ""} alt={getFullName()} />
|
<AvatarImage src={userAvatar || ""} alt={username} />
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">
|
<span className="truncate font-semibold">
|
||||||
{getFullName()}
|
{username}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs">{userEmail}</span>
|
<span className="truncate text-xs">{userEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -31,6 +31,8 @@ import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "@/app/(pages)/(admin)/dashboard/user-management/action";
|
} from "@/app/(pages)/(admin)/dashboard/user-management/action";
|
||||||
|
import { useProfileFormHandlers } from "../../dashboard/user-management/_handlers/use-profile-form";
|
||||||
|
import { CTexts } from "@/app/_lib/const/string";
|
||||||
|
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
username: z.string().nullable().optional(),
|
username: z.string().nullable().optional(),
|
||||||
|
@ -44,71 +46,83 @@ interface ProfileSettingsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileSettings({ user }: ProfileSettingsProps) {
|
export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
// const [isPending, setIsepisPending] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
// const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Use profile data with fallbacks
|
// // Use profile data with fallbacks
|
||||||
|
// const username = user?.profile?.username || "";
|
||||||
|
// const email = user?.email || "";
|
||||||
|
// const userAvatar = user?.profile?.avatar || "";
|
||||||
|
|
||||||
|
// const form = useForm<ProfileFormValues>({
|
||||||
|
// resolver: zodResolver(profileFormSchema),
|
||||||
|
// defaultValues: {
|
||||||
|
// username: username || "",
|
||||||
|
// avatar: userAvatar || "",
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
// const file = e.target.files?.[0];
|
||||||
|
|
||||||
|
// if (!file || !user?.id || !user?.email) return;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// setIsepisPending(true);
|
||||||
|
|
||||||
|
// // Upload avatar to storage
|
||||||
|
// // const publicUrl = await uploadAvatar(user.id, user.email, file);
|
||||||
|
|
||||||
|
// // console.log("publicUrl", publicUrl);
|
||||||
|
|
||||||
|
// // Update the form value
|
||||||
|
// // form.setValue("avatar", publicUrl);
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Error uploading avatar:", error);
|
||||||
|
// } finally {
|
||||||
|
// setIsepisPending(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleAvatarClick = () => {
|
||||||
|
// fileInputRef.current?.click();
|
||||||
|
// };
|
||||||
|
|
||||||
|
// async function onSubmit(data: ProfileFormValues) {
|
||||||
|
// try {
|
||||||
|
// if (!user?.id) return;
|
||||||
|
|
||||||
|
// // Update profile in database
|
||||||
|
// const { error } = await updateUser(user.id, {
|
||||||
|
// profile: {
|
||||||
|
// avatar: data.avatar || undefined,
|
||||||
|
// username: data.username || undefined,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (error) throw error;
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Error updating profile:", error);
|
||||||
|
// }
|
||||||
|
|
||||||
|
const email = user?.email || "";
|
||||||
const username = user?.profile?.username || "";
|
const username = user?.profile?.username || "";
|
||||||
const userEmail = user?.email || "";
|
|
||||||
const userAvatar = user?.profile?.avatar || "";
|
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const {
|
||||||
resolver: zodResolver(profileFormSchema),
|
form,
|
||||||
defaultValues: {
|
fileInputRef,
|
||||||
username: username || "",
|
handleFileChange,
|
||||||
avatar: userAvatar || "",
|
handleAvatarClick,
|
||||||
},
|
isPending,
|
||||||
});
|
onSubmit,
|
||||||
|
} = useProfileFormHandlers({ user });
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
|
|
||||||
if (!file || !user?.id || !user?.email) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsUploading(true);
|
|
||||||
|
|
||||||
// Upload avatar to storage
|
|
||||||
// const publicUrl = await uploadAvatar(user.id, user.email, file);
|
|
||||||
|
|
||||||
// console.log("publicUrl", publicUrl);
|
|
||||||
|
|
||||||
// Update the form value
|
|
||||||
// form.setValue("avatar", publicUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error uploading avatar:", error);
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
|
||||||
try {
|
|
||||||
if (!user?.id) return;
|
|
||||||
|
|
||||||
// Update profile in database
|
|
||||||
const { error } = await updateUser(user.id, {
|
|
||||||
profile: {
|
|
||||||
avatar: data.avatar || undefined,
|
|
||||||
username: data.username || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating profile:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
|
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
|
||||||
<div className="space-y-14 min-h-screen p-8 max-w-4xl mx-auto">
|
<div className="space-y-14 min-h-screen p-8 max-w-4xl mx-auto">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
<form onSubmit={() => { }} className="space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-4 mb-4">
|
<div className="space-y-4 mb-4">
|
||||||
<h3 className="text-lg font-semibold">Account</h3>
|
<h3 className="text-lg font-semibold">Account</h3>
|
||||||
|
@ -120,16 +134,21 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
>
|
>
|
||||||
<Avatar className="h-16 w-16">
|
<Avatar className="h-16 w-16">
|
||||||
<AvatarImage
|
{isPending ? (
|
||||||
src={form.watch("avatar") || ""}
|
<div className="h-full w-full bg-muted animate-pulse rounded-full" />
|
||||||
alt={username}
|
) : (
|
||||||
/>
|
<>
|
||||||
<AvatarFallback>
|
<AvatarImage
|
||||||
{username?.[0]?.toUpperCase() ||
|
src={user?.profile?.avatar || ""}
|
||||||
userEmail?.[0]?.toUpperCase()}
|
alt={username}
|
||||||
</AvatarFallback>
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{username?.[0]?.toUpperCase() || email?.[0]?.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{isUploading ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="h-5 w-5 text-white" />
|
<ImageIcon className="h-5 w-5 text-white" />
|
||||||
|
@ -139,10 +158,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={CTexts.ALLOWED_FILE_TYPES.join(",")}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={isUploading}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
|
@ -154,10 +173,11 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={userEmail.split("@")[0]}
|
// placeholder={user?.profile?.username || ""}
|
||||||
className="bg-muted/50 w-80"
|
className="bg-muted/50 w-80"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || userEmail.split("@")[0]}
|
value={field.value || user?.profile?.username || ""}
|
||||||
|
disabled={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
@ -172,7 +192,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
disabled={isUploading || form.formState.isSubmitting}
|
disabled={isPending || form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? (
|
{form.formState.isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
|
@ -195,7 +215,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>Email</Label>
|
<Label>Email</Label>
|
||||||
<p className="text-sm text-muted-foreground">{userEmail}</p>
|
<p className="text-sm text-muted-foreground">{email}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Change email
|
Change email
|
||||||
|
|
|
@ -28,7 +28,6 @@ import {
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { User } from "@/src/entities/models/users/users.model";
|
|
||||||
import { ProfileSettings } from "./profile-settings";
|
import { ProfileSettings } from "./profile-settings";
|
||||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -36,9 +35,10 @@ import { useState } from "react";
|
||||||
import NotificationsSetting from "./notification-settings";
|
import NotificationsSetting from "./notification-settings";
|
||||||
import PreferencesSettings from "./preference-settings";
|
import PreferencesSettings from "./preference-settings";
|
||||||
import ImportData from "./import-data";
|
import ImportData from "./import-data";
|
||||||
|
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
user: User | null;
|
user: IUserSchema | null;
|
||||||
trigger: React.ReactNode;
|
trigger: React.ReactNode;
|
||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
@ -67,7 +67,7 @@ export function SettingsDialog({
|
||||||
const [selectedTab, setSelectedTab] = useState(defaultTab);
|
const [selectedTab, setSelectedTab] = useState(defaultTab);
|
||||||
|
|
||||||
// Get user display name
|
// Get user display name
|
||||||
const preferredName = user?.profile?.first_name || "";
|
const preferredName = user?.profile?.username || "";
|
||||||
const userEmail = user?.email || "";
|
const userEmail = user?.email || "";
|
||||||
const displayName = preferredName || userEmail?.split("@")[0] || "User";
|
const displayName = preferredName || userEmail?.split("@")[0] || "User";
|
||||||
const userAvatar = user?.profile?.avatar || "";
|
const userAvatar = user?.profile?.avatar || "";
|
||||||
|
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Loader2, ShieldAlert } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/app/_components/ui/alert-dialog"
|
||||||
|
import { Button } from "@/app/_components/ui/button"
|
||||||
|
|
||||||
|
import { Label } from "@/app/_components/ui/label"
|
||||||
|
import { Input } from "@/app/_components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/app/_components/ui/radio-group"
|
||||||
|
import { ValidBanDuration } from "@/app/_lib/types/ban-duration"
|
||||||
|
|
||||||
|
interface BanUserDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onConfirm: (duration: ValidBanDuration) => void
|
||||||
|
isPending?: boolean
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BanDurationType = "preset" | "custom"
|
||||||
|
|
||||||
|
export function BanUserDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
isPending = false,
|
||||||
|
userId,
|
||||||
|
}: BanUserDialogProps) {
|
||||||
|
const [durationType, setDurationType] = useState<BanDurationType>("preset")
|
||||||
|
const [presetDuration, setPresetDuration] = useState("24h")
|
||||||
|
const [customValue, setCustomValue] = useState("1")
|
||||||
|
const [customUnit, setCustomUnit] = useState("days")
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
let duration = ""
|
||||||
|
|
||||||
|
if (durationType === "preset") {
|
||||||
|
duration = presetDuration
|
||||||
|
} else {
|
||||||
|
// Convert to hours for consistency
|
||||||
|
switch (customUnit) {
|
||||||
|
case "hours":
|
||||||
|
duration = `${customValue}h`
|
||||||
|
break
|
||||||
|
case "days":
|
||||||
|
duration = `${parseInt(customValue) * 24}h`
|
||||||
|
break
|
||||||
|
case "weeks":
|
||||||
|
duration = `${parseInt(customValue) * 24 * 7}h`
|
||||||
|
break
|
||||||
|
case "months":
|
||||||
|
duration = `${parseInt(customValue) * 24 * 30}h` // Approximation
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
duration = `${customValue}h`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm(duration as ValidBanDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Ban User</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will prevent the user from accessing the system until the ban expires.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<RadioGroup
|
||||||
|
value={durationType}
|
||||||
|
onValueChange={(value) => setDurationType(value as BanDurationType)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<RadioGroupItem value="preset" id="preset" />
|
||||||
|
<div className="grid gap-2.5 w-full">
|
||||||
|
<Label htmlFor="preset" className="font-medium">Use preset duration</Label>
|
||||||
|
<Select
|
||||||
|
value={presetDuration}
|
||||||
|
onValueChange={setPresetDuration}
|
||||||
|
disabled={durationType !== "preset"}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select duration" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1h">1 hour</SelectItem>
|
||||||
|
<SelectItem value="12h">12 hours</SelectItem>
|
||||||
|
<SelectItem value="24h">24 hours</SelectItem>
|
||||||
|
<SelectItem value="72h">3 days</SelectItem>
|
||||||
|
<SelectItem value="168h">1 week</SelectItem>
|
||||||
|
<SelectItem value="720h">30 days</SelectItem>
|
||||||
|
<SelectItem value="10000h">Permanent</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<RadioGroupItem value="custom" id="custom" />
|
||||||
|
<div className="grid gap-2.5 w-full">
|
||||||
|
<Label htmlFor="custom" className="font-medium">Custom duration</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
disabled={durationType !== "custom"}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={customUnit}
|
||||||
|
onValueChange={setCustomUnit}
|
||||||
|
disabled={durationType !== "custom"}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hours">Hours</SelectItem>
|
||||||
|
<SelectItem value="days">Days</SelectItem>
|
||||||
|
<SelectItem value="weeks">Weeks</SelectItem>
|
||||||
|
<SelectItem value="months">Months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="bg-yellow-500 text-white hover:bg-yellow-600"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Banning...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldAlert className="h-4 w-4 mr-2" />
|
||||||
|
Ban User
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
|
@ -28,6 +28,9 @@ import { Button } from "@/app/_components/ui/button";
|
||||||
import { Label } from "@/app/_components/ui/label";
|
import { Label } from "@/app/_components/ui/label";
|
||||||
import { ImageIcon, Loader2 } from "lucide-react";
|
import { ImageIcon, Loader2 } from "lucide-react";
|
||||||
import { createClient } from "@/app/_utils/supabase/client";
|
import { createClient } from "@/app/_utils/supabase/client";
|
||||||
|
import { getFullName, getInitials } from "@/app/_utils/common";
|
||||||
|
import { useProfileFormHandlers } from "../_handlers/use-profile-form";
|
||||||
|
import { CTexts } from "@/app/_lib/const/string";
|
||||||
|
|
||||||
// Profile update form schema
|
// Profile update form schema
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
|
@ -45,121 +48,120 @@ interface ProfileFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// const [isLoading, setIsLoading] = useState(false);
|
||||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(
|
// const [avatarPreview, setAvatarPreview] = useState<string | null>(
|
||||||
user?.profile?.avatar || null
|
// user?.profile?.avatar || null
|
||||||
);
|
// );
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const supabase = createClient();
|
// const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// const supabase = createClient();
|
||||||
|
|
||||||
|
// // Use profile data with fallbacks
|
||||||
|
// const firstName = user?.profile?.first_name || "";
|
||||||
|
// const lastName = user?.profile?.last_name || "";
|
||||||
|
// const email = user?.email || "";
|
||||||
|
// const userBio = user?.profile?.bio || "";
|
||||||
|
// const username = user?.profile?.username || "";
|
||||||
|
|
||||||
|
// // Setup form with react-hook-form and zod validation
|
||||||
|
// const form = useForm<ProfileFormValues>({
|
||||||
|
// resolver: zodResolver(profileFormSchema),
|
||||||
|
// defaultValues: {
|
||||||
|
// first_name: firstName || "",
|
||||||
|
// last_name: lastName || "",
|
||||||
|
// bio: userBio || "",
|
||||||
|
// avatar: user?.profile?.avatar || "",
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Handle avatar file upload
|
||||||
|
// const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
// const file = e.target.files?.[0];
|
||||||
|
// if (!file || !user?.id) return;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// setIsLoading(true);
|
||||||
|
|
||||||
|
// // Create a preview of the selected image
|
||||||
|
// const objectUrl = URL.createObjectURL(file);
|
||||||
|
// setAvatarPreview(objectUrl);
|
||||||
|
|
||||||
|
// // Upload to Supabase Storage
|
||||||
|
// const fileExt = file.name.split(".").pop();
|
||||||
|
// const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||||
|
// const filePath = `avatars/${fileName}`;
|
||||||
|
|
||||||
|
// const { error: uploadError, data } = await supabase.storage
|
||||||
|
// .from("profiles")
|
||||||
|
// .upload(filePath, file, {
|
||||||
|
// upsert: true,
|
||||||
|
// contentType: file.type,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (uploadError) {
|
||||||
|
// throw uploadError;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Get the public URL
|
||||||
|
// const {
|
||||||
|
// data: { publicUrl },
|
||||||
|
// } = supabase.storage.from("profiles").getPublicUrl(filePath);
|
||||||
|
|
||||||
|
// // Update the form value
|
||||||
|
// form.setValue("avatar", publicUrl);
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Error uploading avatar:", error);
|
||||||
|
// // Revert to previous avatar if upload fails
|
||||||
|
// setAvatarPreview(user?.profile?.avatar || null);
|
||||||
|
// } finally {
|
||||||
|
// setIsLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Trigger file input click
|
||||||
|
// const handleAvatarClick = () => {
|
||||||
|
// fileInputRef.current?.click();
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Handle form submission
|
||||||
|
// async function onSubmit(data: ProfileFormValues) {
|
||||||
|
// try {
|
||||||
|
// if (!user?.id) return;
|
||||||
|
|
||||||
|
// // Update profile in database
|
||||||
|
// const { error } = await supabase
|
||||||
|
// .from("profiles")
|
||||||
|
// .update({
|
||||||
|
// first_name: data.first_name,
|
||||||
|
// last_name: data.last_name,
|
||||||
|
// bio: data.bio,
|
||||||
|
// avatar: data.avatar,
|
||||||
|
// })
|
||||||
|
// .eq("user_id", user.id);
|
||||||
|
|
||||||
|
// if (error) throw error;
|
||||||
|
|
||||||
|
// // Call success callback
|
||||||
|
// onSuccess?.();
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Error updating profile:", error);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// Use profile data with fallbacks
|
|
||||||
const firstName = user?.profile?.first_name || "";
|
const firstName = user?.profile?.first_name || "";
|
||||||
const lastName = user?.profile?.last_name || "";
|
const lastName = user?.profile?.last_name || "";
|
||||||
const userEmail = user?.email || "";
|
const email = user?.email || "";
|
||||||
const userBio = user?.profile?.bio || "";
|
const username = user?.profile?.username || "";
|
||||||
|
|
||||||
const getFullName = () => {
|
const {
|
||||||
return `${firstName} ${lastName}`.trim() || "User";
|
isPending,
|
||||||
};
|
avatarPreview,
|
||||||
|
fileInputRef,
|
||||||
// Generate initials for avatar fallback
|
form,
|
||||||
const getInitials = () => {
|
handleFileChange,
|
||||||
if (firstName && lastName) {
|
handleAvatarClick,
|
||||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
onSubmit,
|
||||||
}
|
} = useProfileFormHandlers({ user, onSuccess })
|
||||||
if (firstName) {
|
|
||||||
return firstName[0].toUpperCase();
|
|
||||||
}
|
|
||||||
if (userEmail) {
|
|
||||||
return userEmail[0].toUpperCase();
|
|
||||||
}
|
|
||||||
return "U";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Setup form with react-hook-form and zod validation
|
|
||||||
const form = useForm<ProfileFormValues>({
|
|
||||||
resolver: zodResolver(profileFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
first_name: firstName || "",
|
|
||||||
last_name: lastName || "",
|
|
||||||
bio: userBio || "",
|
|
||||||
avatar: user?.profile?.avatar || "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle avatar file upload
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file || !user?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
// Create a preview of the selected image
|
|
||||||
const objectUrl = URL.createObjectURL(file);
|
|
||||||
setAvatarPreview(objectUrl);
|
|
||||||
|
|
||||||
// Upload to Supabase Storage
|
|
||||||
const fileExt = file.name.split(".").pop();
|
|
||||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
|
||||||
const filePath = `avatars/${fileName}`;
|
|
||||||
|
|
||||||
const { error: uploadError, data } = await supabase.storage
|
|
||||||
.from("profiles")
|
|
||||||
.upload(filePath, file, {
|
|
||||||
upsert: true,
|
|
||||||
contentType: file.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
throw uploadError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the public URL
|
|
||||||
const {
|
|
||||||
data: { publicUrl },
|
|
||||||
} = supabase.storage.from("profiles").getPublicUrl(filePath);
|
|
||||||
|
|
||||||
// Update the form value
|
|
||||||
form.setValue("avatar", publicUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error uploading avatar:", error);
|
|
||||||
// Revert to previous avatar if upload fails
|
|
||||||
setAvatarPreview(user?.profile?.avatar || null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trigger file input click
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
|
||||||
try {
|
|
||||||
if (!user?.id) return;
|
|
||||||
|
|
||||||
// Update profile in database
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.update({
|
|
||||||
first_name: data.first_name,
|
|
||||||
last_name: data.last_name,
|
|
||||||
bio: data.bio,
|
|
||||||
avatar: data.avatar,
|
|
||||||
})
|
|
||||||
.eq("user_id", user.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Call success callback
|
|
||||||
onSuccess?.();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating profile:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
@ -172,14 +174,14 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||||
>
|
>
|
||||||
<Avatar className="h-24 w-24 border-2 border-border">
|
<Avatar className="h-24 w-24 border-2 border-border">
|
||||||
{avatarPreview ? (
|
{avatarPreview ? (
|
||||||
<AvatarImage src={avatarPreview} alt={getFullName()} />
|
<AvatarImage src={avatarPreview} alt={username} />
|
||||||
) : (
|
) : (
|
||||||
<AvatarFallback className="text-2xl">
|
<AvatarFallback className="text-2xl">
|
||||||
{getInitials()}
|
{getInitials(firstName, lastName, email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{isLoading ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-6 w-6 text-white animate-spin" />
|
<Loader2 className="h-6 w-6 text-white animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="h-6 w-6 text-white" />
|
<ImageIcon className="h-6 w-6 text-white" />
|
||||||
|
@ -190,10 +192,10 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept=""
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={isLoading}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="avatar-upload"
|
htmlFor="avatar-upload"
|
||||||
|
@ -264,7 +266,7 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isLoading || form.formState.isSubmitting}
|
disabled={isPending || form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? (
|
{form.formState.isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -32,16 +32,10 @@ import {
|
||||||
Copy,
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
|
||||||
banUser,
|
|
||||||
deleteUser,
|
|
||||||
unbanUser,
|
|
||||||
} from "@/app/(pages)/(admin)/dashboard/user-management/action";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { sendMagicLink, sendPasswordRecovery } from "@/app/(pages)/(auth)/action";
|
|
||||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||||
import { formatDate } from "@/app/_utils/common";
|
import { formatDate } from "@/app/_utils/common";
|
||||||
import { useUserDetailSheetHandlers } from "../_handlers/use-detail-sheet";
|
import { useUserDetailSheetHandlers } from "../_handlers/use-detail-sheet";
|
||||||
|
import { CAlertDialog } from "@/app/_components/alert-dialog";
|
||||||
|
|
||||||
interface UserDetailSheetProps {
|
interface UserDetailSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -72,7 +66,7 @@ export function UserDetailSheet({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="sm:max-w-md md:max-w-lg overflow-y-auto">
|
<SheetContent className="sm:max-w-md md:max-w-xl overflow-y-auto">
|
||||||
<SheetHeader className="space-y-1">
|
<SheetHeader className="space-y-1">
|
||||||
<SheetTitle className="text-xl flex items-center gap-2">
|
<SheetTitle className="text-xl flex items-center gap-2">
|
||||||
{user.email}
|
{user.email}
|
||||||
|
@ -80,7 +74,7 @@ export function UserDetailSheet({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
onClick={() => handleCopyItem(user.email ?? "")}
|
onClick={() => handleCopyItem(user.email ?? "", "Email")}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -108,7 +102,7 @@ export function UserDetailSheet({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-4 w-4 ml-2"
|
className="h-4 w-4 ml-2"
|
||||||
onClick={() => handleCopyItem(user.id)}
|
onClick={() => handleCopyItem(user.id, "UID")}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -172,7 +166,7 @@ export function UserDetailSheet({
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Email</div>
|
<div className="font-medium">Email</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Signed in with a email account via OAuth
|
Signed in with a email account
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -290,56 +284,18 @@ export function UserDetailSheet({
|
||||||
User will no longer have access to the project
|
User will no longer have access to the project
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
<CAlertDialog
|
||||||
<AlertDialogTrigger asChild>
|
triggerText="Delete user"
|
||||||
<Button
|
triggerIcon={<Trash2 className="h-4 w-4" />}
|
||||||
variant="destructive"
|
title="Are you absolutely sure?"
|
||||||
size="sm"
|
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||||
disabled={isDeletePending}
|
confirmText="Delete"
|
||||||
>
|
onConfirm={handleDeleteUser}
|
||||||
{isDeletePending ? (
|
isPending={isDeletePending}
|
||||||
<>
|
pendingText="Deleting..."
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
variant="destructive"
|
||||||
Deleting...
|
size="sm"
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete user
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Are you absolutely sure?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently
|
|
||||||
delete the user account and remove their data from our
|
|
||||||
servers.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDeleteUser}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
disabled={isDeletePending}
|
|
||||||
>
|
|
||||||
{isDeletePending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Deleting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Delete"
|
|
||||||
)}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -151,7 +151,11 @@ export default function UserManagement() {
|
||||||
onOpenChange={setIsAddUserOpen}
|
onOpenChange={setIsAddUserOpen}
|
||||||
onUserAdded={() => { }}
|
onUserAdded={() => { }}
|
||||||
/>
|
/>
|
||||||
<InviteUserDialog open={isInviteUserOpen} onOpenChange={setIsInviteUserOpen} onUserInvited={() => refetch()} />
|
<InviteUserDialog
|
||||||
|
open={isInviteUserOpen}
|
||||||
|
onOpenChange={setIsInviteUserOpen}
|
||||||
|
onUserInvited={() => { }}
|
||||||
|
/>
|
||||||
{updateUser && (
|
{updateUser && (
|
||||||
<UserProfileSheet
|
<UserProfileSheet
|
||||||
open={isUpdateOpen}
|
open={isUpdateOpen}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { ColumnDef, HeaderContext } from "@tanstack/react-table"
|
import type { ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||||
import type { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
import type { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||||
import { ListFilter, MoreHorizontal, PenIcon as UserPen, Trash2, ShieldAlert } from "lucide-react"
|
import { ListFilter, MoreHorizontal, PenIcon as UserPen, Trash2, ShieldAlert, ShieldCheck } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
@ -16,7 +15,11 @@ import { Input } from "@/app/_components/ui/input"
|
||||||
import { Avatar } from "@/app/_components/ui/avatar"
|
import { Avatar } from "@/app/_components/ui/avatar"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Badge } from "@/app/_components/ui/badge"
|
import { Badge } from "@/app/_components/ui/badge"
|
||||||
import { useBanUserMutation, useDeleteUserMutation, useUnbanUserMutation } from "../_queries/mutations"
|
import { CAlertDialog } from "@/app/_components/alert-dialog"
|
||||||
|
import { useCreateUserColumn } from "../_handlers/use-create-user-column"
|
||||||
|
import { BanUserDialog } from "./ban-user-dialog"
|
||||||
|
import { ValidBanDuration } from "@/app/_lib/types/ban-duration"
|
||||||
|
|
||||||
|
|
||||||
export type UserTableColumn = ColumnDef<IUserSchema, IUserSchema>
|
export type UserTableColumn = ColumnDef<IUserSchema, IUserSchema>
|
||||||
|
|
||||||
|
@ -25,10 +28,26 @@ export const createUserColumns = (
|
||||||
setFilters: (filters: IUserFilterOptionsSchema) => void,
|
setFilters: (filters: IUserFilterOptionsSchema) => void,
|
||||||
handleUserUpdate: (user: IUserSchema) => void,
|
handleUserUpdate: (user: IUserSchema) => void,
|
||||||
): UserTableColumn[] => {
|
): UserTableColumn[] => {
|
||||||
|
const {
|
||||||
const { mutateAsync: deleteUser } = useDeleteUserMutation();
|
deleteDialogOpen,
|
||||||
const { mutateAsync: banUser } = useBanUserMutation();
|
setDeleteDialogOpen,
|
||||||
const { mutateAsync: unbanUser } = useUnbanUserMutation();
|
userToDelete,
|
||||||
|
setUserToDelete,
|
||||||
|
handleDeleteConfirm,
|
||||||
|
isDeletePending,
|
||||||
|
banDialogOpen,
|
||||||
|
setBanDialogOpen,
|
||||||
|
userToBan,
|
||||||
|
setUserToBan,
|
||||||
|
handleBanConfirm,
|
||||||
|
unbanDialogOpen,
|
||||||
|
setUnbanDialogOpen,
|
||||||
|
userToUnban,
|
||||||
|
setUserToUnban,
|
||||||
|
isBanPending,
|
||||||
|
isUnbanPending,
|
||||||
|
handleUnbanConfirm,
|
||||||
|
} = useCreateUserColumn()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -319,7 +338,10 @@ export const createUserColumns = (
|
||||||
Update
|
Update
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => deleteUser(row.original.id)}
|
onClick={() => {
|
||||||
|
setUserToDelete(row.original.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2 text-destructive" />
|
<Trash2 className="h-4 w-4 mr-2 text-destructive" />
|
||||||
Delete
|
Delete
|
||||||
|
@ -327,9 +349,11 @@ export const createUserColumns = (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (row.original.banned_until != null) {
|
if (row.original.banned_until != null) {
|
||||||
unbanUser({ id: row.original.id })
|
setUserToUnban(row.original.id)
|
||||||
|
setUnbanDialogOpen(true)
|
||||||
} else {
|
} else {
|
||||||
banUser({ id: row.original.id, ban_duration: "24h" })
|
setUserToBan(row.original.id)
|
||||||
|
setBanDialogOpen(true)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -338,6 +362,50 @@ export const createUserColumns = (
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Alert Dialog for Delete Confirmation */}
|
||||||
|
{deleteDialogOpen && userToDelete === row.original.id && (
|
||||||
|
<CAlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
title="Are you absolutely sure?"
|
||||||
|
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||||
|
confirmText="Delete"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
isPending={isDeletePending}
|
||||||
|
pendingText="Deleting..."
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert Dialog for Ban Confirmation */}
|
||||||
|
{banDialogOpen && userToBan === row.original.id && (
|
||||||
|
<BanUserDialog
|
||||||
|
open={banDialogOpen}
|
||||||
|
onOpenChange={setBanDialogOpen}
|
||||||
|
onConfirm={(duration: ValidBanDuration) => handleBanConfirm(duration)}
|
||||||
|
isPending={isBanPending}
|
||||||
|
userId={row.original.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert Dialog for Unban Confirmation */}
|
||||||
|
{unbanDialogOpen && userToUnban === row.original.id && (
|
||||||
|
<CAlertDialog
|
||||||
|
open={unbanDialogOpen}
|
||||||
|
onOpenChange={setUnbanDialogOpen}
|
||||||
|
title="Unban User"
|
||||||
|
description="This will restore the user's access to the system. Are you sure you want to unban this user?"
|
||||||
|
confirmText="Unban"
|
||||||
|
onConfirm={handleUnbanConfirm}
|
||||||
|
isPending={isUnbanPending}
|
||||||
|
pendingText="Unbanning..."
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
triggerIcon={<ShieldCheck className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useBanUserMutation, useDeleteUserMutation, useUnbanUserMutation } from "../_queries/mutations"
|
||||||
|
import { ValidBanDuration } from "@/app/_lib/types/ban-duration"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export const useCreateUserColumn = () => {
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Delete user state and handlers
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [userToDelete, setUserToDelete] = useState<string | null>(null)
|
||||||
|
const { mutateAsync: deleteUser, isPending: isDeletePending } = useDeleteUserMutation()
|
||||||
|
|
||||||
|
// Ban user state and handlers
|
||||||
|
const [banDialogOpen, setBanDialogOpen] = useState(false)
|
||||||
|
const [userToBan, setUserToBan] = useState<string | null>(null)
|
||||||
|
const { mutateAsync: banUser, isPending: isBanPending } = useBanUserMutation()
|
||||||
|
|
||||||
|
// Unban user state and handlers
|
||||||
|
const [unbanDialogOpen, setUnbanDialogOpen] = useState(false)
|
||||||
|
const [userToUnban, setUserToUnban] = useState<string | null>(null)
|
||||||
|
const { mutateAsync: unbanUser, isPending: isUnbanPending } = useUnbanUserMutation()
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (userToDelete) {
|
||||||
|
await deleteUser(userToDelete, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (isDeletePending) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||||
|
|
||||||
|
toast.success(`${userToDelete} has been deleted`)
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setUserToDelete(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
|
||||||
|
toast.error("Failed to delete user. Please try again later.")
|
||||||
|
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setUserToDelete(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBanConfirm = async (duration: ValidBanDuration) => {
|
||||||
|
if (userToBan) {
|
||||||
|
await banUser({ id: userToBan, ban_duration: duration }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (!isBanPending) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||||
|
|
||||||
|
toast(`${userToBan} has been banned`)
|
||||||
|
|
||||||
|
setBanDialogOpen(false)
|
||||||
|
setUserToBan(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to ban user. Please try again later.")
|
||||||
|
|
||||||
|
setBanDialogOpen(false)
|
||||||
|
setUserToBan(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnbanConfirm = async () => {
|
||||||
|
if (userToUnban) {
|
||||||
|
await unbanUser({ id: userToUnban }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (!isUnbanPending) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||||
|
|
||||||
|
toast(`${userToUnban} has been unbanned`)
|
||||||
|
|
||||||
|
setUnbanDialogOpen(false)
|
||||||
|
setUserToUnban(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to unban user. Please try again later.")
|
||||||
|
|
||||||
|
setUnbanDialogOpen(false)
|
||||||
|
setUserToUnban(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Delete
|
||||||
|
deleteDialogOpen,
|
||||||
|
setDeleteDialogOpen,
|
||||||
|
userToDelete,
|
||||||
|
setUserToDelete,
|
||||||
|
handleDeleteConfirm,
|
||||||
|
isDeletePending,
|
||||||
|
|
||||||
|
// Ban
|
||||||
|
banDialogOpen,
|
||||||
|
setBanDialogOpen,
|
||||||
|
userToBan,
|
||||||
|
setUserToBan,
|
||||||
|
handleBanConfirm,
|
||||||
|
isBanPending,
|
||||||
|
|
||||||
|
// Unban
|
||||||
|
unbanDialogOpen,
|
||||||
|
setUnbanDialogOpen,
|
||||||
|
userToUnban,
|
||||||
|
setUserToUnban,
|
||||||
|
handleUnbanConfirm,
|
||||||
|
isUnbanPending,
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import { toast } from "sonner";
|
||||||
import { useBanUserMutation, useDeleteUserMutation, useUnbanUserMutation } from "../_queries/mutations";
|
import { useBanUserMutation, useDeleteUserMutation, useUnbanUserMutation } from "../_queries/mutations";
|
||||||
import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app/(pages)/(auth)/_queries/mutations";
|
import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app/(pages)/(auth)/_queries/mutations";
|
||||||
import { ValidBanDuration } from "@/app/_lib/types/ban-duration";
|
import { ValidBanDuration } from "@/app/_lib/types/ban-duration";
|
||||||
import { handleCopyItem } from "@/app/_utils/common";
|
import { copyItem } from "@/app/_utils/common";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: {
|
export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: {
|
||||||
|
@ -24,30 +24,54 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
||||||
const handleDeleteUser = async () => {
|
const handleDeleteUser = async () => {
|
||||||
await deleteUser(user.id, {
|
await deleteUser(user.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast.success(`${user.email} has been deleted`);
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendPasswordRecovery = async () => {
|
const handleSendPasswordRecovery = async () => {
|
||||||
if (!user.email) {
|
if (user.email) {
|
||||||
toast.error("User has no email address");
|
await sendPasswordRecovery(user.email, {
|
||||||
return;
|
onSuccess: () => {
|
||||||
}
|
toast.success(`Password recovery email sent to ${user.email}`);
|
||||||
await sendPasswordRecovery(user.email);
|
|
||||||
};
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const handleSendMagicLink = async () => {
|
const handleSendMagicLink = async () => {
|
||||||
if (!user.email) {
|
if (user.email) {
|
||||||
toast.error("User has no email address");
|
await sendMagicLink(user.email, {
|
||||||
return;
|
onSuccess: () => {
|
||||||
|
toast.success(`Magic link sent to ${user.email}`);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await sendMagicLink(user.email);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBanUser = async (ban_duration: ValidBanDuration = "24h") => {
|
const handleBanUser = async (ban_duration: ValidBanDuration = "24h") => {
|
||||||
await banUser({ id: user.id, ban_duration: ban_duration }, {
|
await banUser({ id: user.id, ban_duration: ban_duration }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast(`${user.email} has been banned`);
|
||||||
|
|
||||||
onUserUpdated();
|
onUserUpdated();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -55,7 +79,12 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
||||||
|
|
||||||
const handleUnbanUser = async () => {
|
const handleUnbanUser = async () => {
|
||||||
await unbanUser({ id: user.id }, {
|
await unbanUser({ id: user.id }, {
|
||||||
onSuccess: onUserUpdated
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast(`${user.email} has been unbanned`);
|
||||||
|
|
||||||
|
onUserUpdated();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -81,6 +110,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyItem = async (item: string, label: string) => {
|
||||||
|
if (item) copyItem(item, { label: label });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleDeleteUser,
|
handleDeleteUser,
|
||||||
handleSendPasswordRecovery,
|
handleSendPasswordRecovery,
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
import { createClient } from "@/app/_utils/supabase/client"
|
||||||
|
import type { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useRef, useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useUnbanUserMutation, useUpdateUserMutation, useUploadAvatarMutation } from "../_queries/mutations"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { CNumbers } from "@/app/_lib/const/number"
|
||||||
|
import { CTexts } from "@/app/_lib/const/string"
|
||||||
|
|
||||||
|
// Profile update form schema
|
||||||
|
const profileFormSchema = z.object({
|
||||||
|
username: z.string().nullable().optional(),
|
||||||
|
first_name: z.string().nullable().optional(),
|
||||||
|
last_name: z.string().nullable().optional(),
|
||||||
|
bio: z.string().nullable().optional(),
|
||||||
|
avatar: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
user: IUserSchema | null
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProfileFormHandlers = ({ user, onSuccess }: ProfileFormProps) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: updateUser,
|
||||||
|
isPending,
|
||||||
|
error
|
||||||
|
} = useUpdateUserMutation()
|
||||||
|
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.profile?.avatar || null)
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Setup form with react-hook-form and zod validation
|
||||||
|
const form = useForm<ProfileFormValues>({
|
||||||
|
resolver: zodResolver(profileFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
first_name: user?.profile?.first_name || "",
|
||||||
|
last_name: user?.profile?.last_name || "",
|
||||||
|
bio: user?.profile?.bio || "",
|
||||||
|
avatar: user?.profile?.avatar || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetAvatarValue = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle avatar file upload
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
|
||||||
|
if (!file || !user?.id) {
|
||||||
|
|
||||||
|
toast.error("No file selected")
|
||||||
|
|
||||||
|
resetAvatarValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > CNumbers.MAX_FILE_AVATAR_SIZE) {
|
||||||
|
toast.error(`File size must be less than ${CNumbers.MAX_FILE_AVATAR_SIZE / 1024 / 1024} MB`)
|
||||||
|
|
||||||
|
// Reset the file input
|
||||||
|
resetAvatarValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!CTexts.ALLOWED_FILE_TYPES.includes(file.type)) {
|
||||||
|
toast.error("Invalid file type. Only PNG and JPG are allowed")
|
||||||
|
|
||||||
|
// Reset the file input
|
||||||
|
resetAvatarValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Create a preview of the selected image
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
setAvatarPreview(objectUrl)
|
||||||
|
|
||||||
|
// Upload to Supabase Storage
|
||||||
|
const fileExt = file.name.split(".").pop()
|
||||||
|
const fileName = `AVR-${user.email?.split("@")[0]}`
|
||||||
|
const filePath = `${user.id}/${fileName}`
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const { error: uploadError, data } = await supabase.storage.from("avatars").upload(filePath, file, {
|
||||||
|
upsert: true,
|
||||||
|
contentType: file.type,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
|
||||||
|
toast.error("Error uploading avatar. Please try again.")
|
||||||
|
|
||||||
|
throw uploadError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the public URL
|
||||||
|
const {
|
||||||
|
data: { publicUrl },
|
||||||
|
} = supabase.storage.from("avatars").getPublicUrl(filePath)
|
||||||
|
|
||||||
|
const uniquePublicUrl = `${publicUrl}?t=${Date.now()}`
|
||||||
|
|
||||||
|
await updateUser({ id: user.id, data: { profile: { avatar: uniquePublicUrl } } })
|
||||||
|
|
||||||
|
form.setValue("avatar", uniquePublicUrl)
|
||||||
|
resetAvatarValue()
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user", "current"] })
|
||||||
|
toast.success("Avatar uploaded successfully")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading avatar:", error)
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
toast.error("Error uploading avatar. Please try again.")
|
||||||
|
|
||||||
|
// Revert to previous avatar if upload fails
|
||||||
|
setAvatarPreview(user?.profile?.avatar || null)
|
||||||
|
|
||||||
|
// Reset the file input
|
||||||
|
resetAvatarValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger file input click
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
|
try {
|
||||||
|
if (!user?.id) return
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Update profile in database
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
first_name: data.first_name,
|
||||||
|
last_name: data.last_name,
|
||||||
|
bio: data.bio,
|
||||||
|
avatar: data.avatar,
|
||||||
|
})
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
toast.success("Profile updated successfully")
|
||||||
|
|
||||||
|
// Invalidate the user query to refresh data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user", "current", user.id] })
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating profile:", error)
|
||||||
|
toast.error("Error updating profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
handleFileChange,
|
||||||
|
handleAvatarClick,
|
||||||
|
onSubmit,
|
||||||
|
isPending,
|
||||||
|
avatarPreview,
|
||||||
|
fileInputRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useUpdateUserMutation } from "../_queries/mutations";
|
import { useUpdateUserMutation } from "../_queries/mutations";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: {
|
export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -11,6 +13,8 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs
|
||||||
onUserUpdated: () => void;
|
onUserUpdated: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: updateUser,
|
mutateAsync: updateUser,
|
||||||
isPending,
|
isPending,
|
||||||
|
@ -54,10 +58,18 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs
|
||||||
const handleUpdateUser = async () => {
|
const handleUpdateUser = async () => {
|
||||||
await updateUser({ id: userData.id, data: form.getValues() }, {
|
await updateUser({ id: userData.id, data: form.getValues() }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||||
|
|
||||||
|
toast.success("User updated successfully")
|
||||||
|
|
||||||
onUserUpdated();
|
onUserUpdated();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|
||||||
|
toast.error("Failed to update user")
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
|
@ -1,6 +1,6 @@
|
||||||
import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model";
|
import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { banUser, createUser, deleteUser, inviteUser, unbanUser, updateUser } from "../action";
|
import { banUser, createUser, deleteUser, inviteUser, unbanUser, updateUser, uploadAvatar } from "../action";
|
||||||
import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model";
|
import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model";
|
||||||
import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model";
|
import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model";
|
||||||
import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model";
|
import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model";
|
||||||
|
@ -49,3 +49,10 @@ export const useUnbanUserMutation = () => {
|
||||||
mutationFn: (credential: ICredentialsUnbanUserSchema) => unbanUser(credential),
|
mutationFn: (credential: ICredentialsUnbanUserSchema) => unbanUser(credential),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useUploadAvatarMutation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationKey: ["user", "upload-avatar"],
|
||||||
|
mutationFn: (args: { userId: string; file: File }) => uploadAvatar(args.userId, args.file),
|
||||||
|
})
|
||||||
|
}
|
|
@ -498,3 +498,52 @@ export async function deleteUser(id: string) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function uploadAvatar(id: string, file: File) {
|
||||||
|
const instrumentationService = getInjection('IInstrumentationService');
|
||||||
|
return await instrumentationService.instrumentServerAction(
|
||||||
|
'uploadAvatar',
|
||||||
|
{ recordResponse: true },
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const uploadAvatarController = getInjection('IUploadAvatarController');
|
||||||
|
const newAvatar = await uploadAvatarController(id, file);
|
||||||
|
|
||||||
|
return { success: true, newAvatar };
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InputParseError) {
|
||||||
|
// return {
|
||||||
|
// error: err.message,
|
||||||
|
// };
|
||||||
|
|
||||||
|
throw new InputParseError(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof UnauthenticatedError) {
|
||||||
|
// return {
|
||||||
|
// error: 'Must be logged in to create a user.',
|
||||||
|
// };
|
||||||
|
throw new UnauthenticatedError('Must be logged in to upload an avatar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
// return {
|
||||||
|
// error: 'User not found.',
|
||||||
|
// };
|
||||||
|
|
||||||
|
throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const crashReporterService = getInjection('ICrashReporterService');
|
||||||
|
crashReporterService.report(err);
|
||||||
|
// return {
|
||||||
|
// error:
|
||||||
|
// 'An error happened. The developers have been notified. Please try again later.',
|
||||||
|
// };
|
||||||
|
throw new Error('An error happened. The developers have been notified. Please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -54,7 +54,7 @@ export default function RootLayout({
|
||||||
</nav> */}
|
</nav> */}
|
||||||
<div className="flex flex-col max-w-full p-0">
|
<div className="flex flex-col max-w-full p-0">
|
||||||
{children}
|
{children}
|
||||||
<Toaster theme="system" position="top-right" />
|
<Toaster theme="system" position="top-right" richColors />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto">
|
{/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto">
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/app/_components/ui/alert-dialog"
|
||||||
|
import { Button, type ButtonProps } from "@/app/_components/ui/button"
|
||||||
|
|
||||||
|
interface CAlertDialogProps {
|
||||||
|
triggerText?: React.ReactNode
|
||||||
|
triggerIcon?: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
isPending?: boolean
|
||||||
|
pendingText?: string
|
||||||
|
variant?: ButtonProps["variant"]
|
||||||
|
size?: ButtonProps["size"]
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CAlertDialog({
|
||||||
|
triggerText,
|
||||||
|
triggerIcon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
onConfirm,
|
||||||
|
isPending = false,
|
||||||
|
pendingText = "Processing...",
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: CAlertDialogProps) {
|
||||||
|
// If open and onOpenChange are provided, use them for controlled behavior
|
||||||
|
const isControlled = open !== undefined && onOpenChange !== undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{/* Only render the trigger if not in controlled mode */}
|
||||||
|
{!isControlled && (
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant={variant} size={size} disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{pendingText}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{triggerIcon && <span className="mr-2">{triggerIcon}</span>}
|
||||||
|
{triggerText}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
)}
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={
|
||||||
|
variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""
|
||||||
|
}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{pendingText}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
confirmText
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
|
@ -29,4 +29,6 @@ export class CNumbers {
|
||||||
// Phone number
|
// Phone number
|
||||||
static readonly PHONE_MIN_LENGTH = 10;
|
static readonly PHONE_MIN_LENGTH = 10;
|
||||||
static readonly PHONE_MAX_LENGTH = 13;
|
static readonly PHONE_MAX_LENGTH = 13;
|
||||||
|
|
||||||
|
static readonly MAX_FILE_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,4 +88,5 @@ export class CTexts {
|
||||||
// Phone number
|
// Phone number
|
||||||
static readonly PHONE_PREFIX = ['+62', '62', '0']
|
static readonly PHONE_PREFIX = ['+62', '62', '0']
|
||||||
|
|
||||||
|
static readonly ALLOWED_FILE_TYPES = ["image/png", "image/jpeg", "image/jpg"];
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,7 +120,8 @@ export const formatDate = (
|
||||||
: format(dateObj, formatPattern);
|
: format(dateObj, formatPattern);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleCopyItem = (item: string, options?: {
|
export const copyItem = (item: string, options?: {
|
||||||
|
label?: string,
|
||||||
onSuccess?: () => void,
|
onSuccess?: () => void,
|
||||||
onError?: (error: unknown) => void
|
onError?: (error: unknown) => void
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -140,7 +141,8 @@ export const handleCopyItem = (item: string, options?: {
|
||||||
|
|
||||||
navigator.clipboard.writeText(item)
|
navigator.clipboard.writeText(item)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Copied to clipboard");
|
const label = options?.label || item;
|
||||||
|
toast.success(`${label} copied to clipboard`);
|
||||||
options?.onSuccess?.();
|
options?.onSuccess?.();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -148,3 +150,154 @@ export const handleCopyItem = (item: string, options?: {
|
||||||
options?.onError?.(error);
|
options?.onError?.(error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string to a human-readable format with type safety.
|
||||||
|
* @param date - The date string to format.
|
||||||
|
* @param options - Formatting options or a format string.
|
||||||
|
* @returns The formatted date string.
|
||||||
|
* @example
|
||||||
|
* // Using default format
|
||||||
|
* formatDate("2025-03-23")
|
||||||
|
*
|
||||||
|
* // Using a custom format string
|
||||||
|
* formatDate("2025-03-23", "yyyy-MM-dd")
|
||||||
|
*
|
||||||
|
* // Using formatting options
|
||||||
|
* formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" })
|
||||||
|
*/
|
||||||
|
export const formatDateWithFallback = (
|
||||||
|
date: string | Date | undefined | null,
|
||||||
|
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||||||
|
): string => {
|
||||||
|
if (!date) {
|
||||||
|
return typeof options === "string"
|
||||||
|
? "-"
|
||||||
|
: (options.fallback || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
// Handle invalid dates
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
return typeof options === "string"
|
||||||
|
? "-"
|
||||||
|
: (options.fallback || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options === "string") {
|
||||||
|
return format(dateObj, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { format: formatPattern = "PPpp", locale } = options;
|
||||||
|
|
||||||
|
return locale
|
||||||
|
? format(dateObj, formatPattern, { locale })
|
||||||
|
: format(dateObj, formatPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateWithLocale = (
|
||||||
|
date: string | Date | undefined | null,
|
||||||
|
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||||||
|
): string => {
|
||||||
|
if (!date) {
|
||||||
|
return typeof options === "string"
|
||||||
|
? "-"
|
||||||
|
: (options.fallback || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
// Handle invalid dates
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
return typeof options === "string"
|
||||||
|
? "-"
|
||||||
|
: (options.fallback || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options === "string") {
|
||||||
|
return format(dateObj, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { format: formatPattern = "PPpp", locale } = options;
|
||||||
|
|
||||||
|
return locale
|
||||||
|
? format(dateObj, formatPattern, { locale })
|
||||||
|
: format(dateObj, formatPattern);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string to a human-readable format with type safety.
|
||||||
|
* @param date - The date string to format.
|
||||||
|
* @param options - Formatting options or a format string.
|
||||||
|
* @returns The formatted date string.
|
||||||
|
* @example
|
||||||
|
* // Using default format
|
||||||
|
* formatDate("2025-03-23")
|
||||||
|
*
|
||||||
|
* // Using a custom format string
|
||||||
|
* formatDate("2025-03-23", "yyyy-MM-dd")
|
||||||
|
*
|
||||||
|
* // Using formatting options
|
||||||
|
* formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" })
|
||||||
|
*/
|
||||||
|
export const formatDateWithLocaleAndFallback = (
|
||||||
|
date: string | Date | undefined | null,
|
||||||
|
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||||||
|
): string => {
|
||||||
|
if (!date) {
|
||||||
|
return typeof options === "string"
|
||||||
|
? "-"
|
||||||
|
: (options.fallback || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
// Handle invalid dates
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
return typeof options === "string"
|
||||||
|
? "-"
|
||||||
|
: (options.fallback || "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options === "string") {
|
||||||
|
return format(dateObj, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { format: formatPattern = "PPpp", locale } = options;
|
||||||
|
|
||||||
|
return locale
|
||||||
|
? format(dateObj, formatPattern, { locale })
|
||||||
|
: format(dateObj, formatPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a full name from first and last names.
|
||||||
|
* @param firstName - The first name.
|
||||||
|
* @param lastName - The last name.
|
||||||
|
* @returns The full name or "User" if both names are empty.
|
||||||
|
*/
|
||||||
|
export const getFullName = (firstName: string, lastName: string): string => {
|
||||||
|
return `${firstName} ${lastName}`.trim() || "User";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates initials for a user based on their first and last names.
|
||||||
|
* @param firstName - The first name.
|
||||||
|
* @param lastName - The last name.
|
||||||
|
* @param email - The email address.
|
||||||
|
* @returns The initials or "U" if both names are empty.
|
||||||
|
*/
|
||||||
|
export const getInitials = (firstName: string, lastName: string, email: string): string => {
|
||||||
|
if (firstName && lastName) {
|
||||||
|
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
if (firstName) {
|
||||||
|
return firstName[0].toUpperCase();
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
return email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -124,6 +124,13 @@ export function createUsersModule() {
|
||||||
DI_SYMBOLS.IUsersRepository
|
DI_SYMBOLS.IUsersRepository
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
usersModule
|
||||||
|
.bind(DI_SYMBOLS.IUploadAvatarUseCase)
|
||||||
|
.toHigherOrderFunction(updateUserUseCase, [
|
||||||
|
DI_SYMBOLS.IInstrumentationService,
|
||||||
|
DI_SYMBOLS.IUsersRepository
|
||||||
|
]);
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
usersModule
|
usersModule
|
||||||
.bind(DI_SYMBOLS.IBanUserController)
|
.bind(DI_SYMBOLS.IBanUserController)
|
||||||
|
@ -208,6 +215,13 @@ export function createUsersModule() {
|
||||||
DI_SYMBOLS.IGetCurrentUserUseCase
|
DI_SYMBOLS.IGetCurrentUserUseCase
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
usersModule
|
||||||
|
.bind(DI_SYMBOLS.IUploadAvatarController)
|
||||||
|
.toHigherOrderFunction(updateUserController, [
|
||||||
|
DI_SYMBOLS.IInstrumentationService,
|
||||||
|
DI_SYMBOLS.IUploadAvatarUseCase,
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
return usersModule;
|
return usersModule;
|
||||||
}
|
}
|
|
@ -37,6 +37,8 @@ import { ISendMagicLinkUseCase } from '@/src/application/use-cases/auth/send-mag
|
||||||
import { ISendPasswordRecoveryUseCase } from '@/src/application/use-cases/auth/send-password-recovery.use-case';
|
import { ISendPasswordRecoveryUseCase } from '@/src/application/use-cases/auth/send-password-recovery.use-case';
|
||||||
import { ISendMagicLinkController } from '@/src/interface-adapters/controllers/auth/send-magic-link.controller';
|
import { ISendMagicLinkController } from '@/src/interface-adapters/controllers/auth/send-magic-link.controller';
|
||||||
import { ISendPasswordRecoveryController } from '@/src/interface-adapters/controllers/auth/send-password-recovery.controller';
|
import { ISendPasswordRecoveryController } from '@/src/interface-adapters/controllers/auth/send-password-recovery.controller';
|
||||||
|
import { IUploadAvatarController } from '@/src/interface-adapters/controllers/users/upload-avatar.controller';
|
||||||
|
import { IUploadAvatarUseCase } from '@/src/application/use-cases/users/upload-avatar.use-case';
|
||||||
|
|
||||||
export const DI_SYMBOLS = {
|
export const DI_SYMBOLS = {
|
||||||
// Services
|
// Services
|
||||||
|
@ -67,6 +69,7 @@ export const DI_SYMBOLS = {
|
||||||
ICreateUserUseCase: Symbol.for('ICreateUserUseCase'),
|
ICreateUserUseCase: Symbol.for('ICreateUserUseCase'),
|
||||||
IUpdateUserUseCase: Symbol.for('IUpdateUserUseCase'),
|
IUpdateUserUseCase: Symbol.for('IUpdateUserUseCase'),
|
||||||
IDeleteUserUseCase: Symbol.for('IDeleteUserUseCase'),
|
IDeleteUserUseCase: Symbol.for('IDeleteUserUseCase'),
|
||||||
|
IUploadAvatarUseCase: Symbol.for('IUploadAvatarUseCase'),
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
ISignInController: Symbol.for('ISignInController'),
|
ISignInController: Symbol.for('ISignInController'),
|
||||||
|
@ -86,6 +89,7 @@ export const DI_SYMBOLS = {
|
||||||
ICreateUserController: Symbol.for('ICreateUserController'),
|
ICreateUserController: Symbol.for('ICreateUserController'),
|
||||||
IUpdateUserController: Symbol.for('IUpdateUserController'),
|
IUpdateUserController: Symbol.for('IUpdateUserController'),
|
||||||
IDeleteUserController: Symbol.for('IDeleteUserController'),
|
IDeleteUserController: Symbol.for('IDeleteUserController'),
|
||||||
|
IUploadAvatarController: Symbol.for('IUploadAvatarController'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DI_RETURN_TYPES {
|
export interface DI_RETURN_TYPES {
|
||||||
|
@ -117,6 +121,7 @@ export interface DI_RETURN_TYPES {
|
||||||
ICreateUserUseCase: ICreateUserUseCase;
|
ICreateUserUseCase: ICreateUserUseCase;
|
||||||
IUpdateUserUseCase: IUpdateUserUseCase;
|
IUpdateUserUseCase: IUpdateUserUseCase;
|
||||||
IDeleteUserUseCase: IDeleteUserUseCase;
|
IDeleteUserUseCase: IDeleteUserUseCase;
|
||||||
|
IUploadAvatarUseCase: IUploadAvatarUseCase;
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
ISignInController: ISignInController;
|
ISignInController: ISignInController;
|
||||||
|
@ -136,5 +141,6 @@ export interface DI_RETURN_TYPES {
|
||||||
ICreateUserController: ICreateUserController;
|
ICreateUserController: ICreateUserController;
|
||||||
IUpdateUserController: IUpdateUserController;
|
IUpdateUserController: IUpdateUserController;
|
||||||
IDeleteUserController: IDeleteUserController;
|
IDeleteUserController: IDeleteUserController;
|
||||||
|
IUploadAvatarController: IUploadAvatarController;
|
||||||
|
|
||||||
}
|
}
|
|
@ -17,6 +17,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
@ -3017,6 +3018,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-direction": "1.1.0",
|
||||||
|
"@radix-ui/react-presence": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.0.2",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.0",
|
||||||
|
"@radix-ui/react-use-size": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
|
|
@ -22,4 +22,5 @@ export interface IUsersRepository {
|
||||||
deleteUser(credential: ICredentialsDeleteUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
deleteUser(credential: ICredentialsDeleteUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
||||||
banUser(credential: ICredentialsBanUserSchema, input: IBanUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
banUser(credential: ICredentialsBanUserSchema, input: IBanUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
||||||
unbanUser(credential: ICredentialsUnbanUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
unbanUser(credential: ICredentialsUnbanUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
||||||
|
uploadAvatar(userId: string, file: File): Promise<string>;
|
||||||
}
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { IUsersRepository } from "../../repositories/users.repository.interface";
|
||||||
|
import { IInstrumentationService } from "../../services/instrumentation.service.interface";
|
||||||
|
|
||||||
|
export type IUploadAvatarUseCase = ReturnType<typeof uploadAvatarUseCase>;
|
||||||
|
|
||||||
|
export const uploadAvatarUseCase = (
|
||||||
|
instrumentationService: IInstrumentationService,
|
||||||
|
usersRepository: IUsersRepository,
|
||||||
|
) => async (userId: string, file: File): Promise<string> => {
|
||||||
|
return await instrumentationService.startSpan({ name: "uploadAvatar Use Case", op: "function" },
|
||||||
|
async () => {
|
||||||
|
const newAvatar = await usersRepository.uploadAvatar(userId, file);
|
||||||
|
|
||||||
|
if (!newAvatar) {
|
||||||
|
throw new Error("Failed to upload avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAvatar;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
|
@ -201,7 +201,26 @@ export class UsersRepository implements IUsersRepository {
|
||||||
throw new NotFoundError("User not found");
|
throw new NotFoundError("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
const userDetail = await db.users.findUnique({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
profile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userDetail) {
|
||||||
|
throw new NotFoundError("User details not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
profile: {
|
||||||
|
user_id: userDetail.id,
|
||||||
|
...userDetail.profile,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.crashReporterService.report(err);
|
this.crashReporterService.report(err);
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -289,11 +308,10 @@ export class UsersRepository implements IUsersRepository {
|
||||||
const queryUpdateSupabaseUser = supabase.auth.admin.updateUserById(credential.id, {
|
const queryUpdateSupabaseUser = supabase.auth.admin.updateUserById(credential.id, {
|
||||||
email: input.email,
|
email: input.email,
|
||||||
email_confirm: input.email_confirmed_at,
|
email_confirm: input.email_confirmed_at,
|
||||||
password: input.encrypted_password ?? undefined,
|
password: input.encrypted_password,
|
||||||
password_hash: input.encrypted_password ?? undefined,
|
password_hash: input.encrypted_password,
|
||||||
phone: input.phone,
|
phone: input.phone,
|
||||||
phone_confirm: input.phone_confirmed_at,
|
phone_confirm: input.phone_confirmed_at,
|
||||||
role: input.role,
|
|
||||||
user_metadata: input.user_metadata,
|
user_metadata: input.user_metadata,
|
||||||
app_metadata: input.app_metadata,
|
app_metadata: input.app_metadata,
|
||||||
});
|
});
|
||||||
|
@ -499,4 +517,31 @@ export class UsersRepository implements IUsersRepository {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async uploadAvatar(userId: string, file: File): Promise<string> {
|
||||||
|
return await this.instrumentationService.startSpan({
|
||||||
|
name: "UsersRepository > uploadAvatar",
|
||||||
|
}, async () => {
|
||||||
|
try {
|
||||||
|
const supabase = this.supabaseAdmin;
|
||||||
|
|
||||||
|
const { data, error } = await supabase.storage.from("avatars").upload(`avatars/${userId}`, file, {
|
||||||
|
cacheControl: "3600",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new DatabaseOperationError("Failed to upload avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: newAvatar } = supabase.storage.from("avatars").getPublicUrl(`avatars/${userId}`);
|
||||||
|
|
||||||
|
return newAvatar.publicUrl || "";
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.crashReporterService.report(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { CNumbers } from "@/app/_lib/const/number";
|
||||||
|
import { CTexts } from "@/app/_lib/const/string";
|
||||||
|
import { IInstrumentationService } from "@/src/application/services/instrumentation.service.interface";
|
||||||
|
import { IUploadAvatarUseCase } from "@/src/application/use-cases/users/upload-avatar.use-case";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
avatar: z.instanceof(File).refine(file => file.size <= CNumbers.MAX_FILE_AVATAR_SIZE, {
|
||||||
|
message: `File size must be less than ${CNumbers.MAX_FILE_AVATAR_SIZE}`,
|
||||||
|
}).refine(file => CTexts.ALLOWED_FILE_TYPES.includes(file.type), {
|
||||||
|
message: "Invalid file type. Only PNG and JPG are allowed",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IUploadAvatarController = ReturnType<typeof uploadAvatarController>;
|
||||||
|
|
||||||
|
export const uploadAvatarController = (
|
||||||
|
instrumentationService: IInstrumentationService,
|
||||||
|
uploadAvatarUseCase: IUploadAvatarUseCase,
|
||||||
|
) => async (id: string, file: File) => {
|
||||||
|
return await instrumentationService.startSpan({ name: "uploadAvatar Controller" }, async () => {
|
||||||
|
const result = inputSchema.safeParse({ id, avatar: file });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error.errors.map(err => err.message).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await uploadAvatarUseCase(result.data.id, result.data.avatar);
|
||||||
|
});
|
||||||
|
};
|
Loading…
Reference in New Issue