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 userEmail = user?.email || "";
|
||||
const userAvatar = user?.profile?.avatar || "";
|
||||
const username = user?.profile?.username || "";
|
||||
|
||||
const getFullName = () => {
|
||||
return `${firstName} ${lastName}`.trim() || "User";
|
||||
return `${firstName} ${lastName}`.trim() || username || "User";
|
||||
};
|
||||
|
||||
// Generate initials for avatar fallback
|
||||
|
@ -58,12 +59,6 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
|||
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();
|
||||
|
||||
function LogoutButton({ handleSignOut, isPending }: { handleSignOut: () => void; isPending: boolean }) {
|
||||
|
@ -99,7 +94,6 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
|||
onClick={() => {
|
||||
handleSignOut();
|
||||
|
||||
// Tutup dialog setelah tombol Log out diklik
|
||||
if (!isPending) {
|
||||
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"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={userAvatar || ""} alt={getFullName()} />
|
||||
<AvatarImage src={userAvatar || ""} alt={username} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
|
@ -154,14 +148,14 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
|
|||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={userAvatar || ""} alt={getFullName()} />
|
||||
<AvatarImage src={userAvatar || ""} alt={username} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{getFullName()}
|
||||
{username}
|
||||
</span>
|
||||
<span className="truncate text-xs">{userEmail}</span>
|
||||
</div>
|
||||
|
|
|
@ -31,6 +31,8 @@ import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
|||
import {
|
||||
updateUser,
|
||||
} 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({
|
||||
username: z.string().nullable().optional(),
|
||||
|
@ -44,71 +46,83 @@ interface ProfileSettingsProps {
|
|||
}
|
||||
|
||||
export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// const [isPending, setIsepisPending] = useState(false);
|
||||
// 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 userEmail = user?.email || "";
|
||||
const userAvatar = user?.profile?.avatar || "";
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
defaultValues: {
|
||||
username: username || "",
|
||||
avatar: userAvatar || "",
|
||||
},
|
||||
});
|
||||
const {
|
||||
form,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
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 (
|
||||
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
|
||||
<div className="space-y-14 min-h-screen p-8 max-w-4xl mx-auto">
|
||||
<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 mb-4">
|
||||
<h3 className="text-lg font-semibold">Account</h3>
|
||||
|
@ -120,16 +134,21 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
|||
onClick={handleAvatarClick}
|
||||
>
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage
|
||||
src={form.watch("avatar") || ""}
|
||||
alt={username}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{username?.[0]?.toUpperCase() ||
|
||||
userEmail?.[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
{isPending ? (
|
||||
<div className="h-full w-full bg-muted animate-pulse rounded-full" />
|
||||
) : (
|
||||
<>
|
||||
<AvatarImage
|
||||
src={user?.profile?.avatar || ""}
|
||||
alt={username}
|
||||
/>
|
||||
<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">
|
||||
{isUploading ? (
|
||||
{isPending ? (
|
||||
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="h-5 w-5 text-white" />
|
||||
|
@ -139,10 +158,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={CTexts.ALLOWED_FILE_TYPES.join(",")}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
|
@ -154,10 +173,11 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
|||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={userEmail.split("@")[0]}
|
||||
// placeholder={user?.profile?.username || ""}
|
||||
className="bg-muted/50 w-80"
|
||||
{...field}
|
||||
value={field.value || userEmail.split("@")[0]}
|
||||
value={field.value || user?.profile?.username || ""}
|
||||
disabled={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
@ -172,7 +192,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={isUploading || form.formState.isSubmitting}
|
||||
disabled={isPending || form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
|
@ -195,7 +215,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Email</Label>
|
||||
<p className="text-sm text-muted-foreground">{userEmail}</p>
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Change email
|
||||
|
|
|
@ -28,7 +28,6 @@ import {
|
|||
IconUsers,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
import type { User } from "@/src/entities/models/users/users.model";
|
||||
import { ProfileSettings } from "./profile-settings";
|
||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { useState } from "react";
|
||||
|
@ -36,9 +35,10 @@ import { useState } from "react";
|
|||
import NotificationsSetting from "./notification-settings";
|
||||
import PreferencesSettings from "./preference-settings";
|
||||
import ImportData from "./import-data";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
user: User | null;
|
||||
user: IUserSchema | null;
|
||||
trigger: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
open?: boolean;
|
||||
|
@ -67,7 +67,7 @@ export function SettingsDialog({
|
|||
const [selectedTab, setSelectedTab] = useState(defaultTab);
|
||||
|
||||
// Get user display name
|
||||
const preferredName = user?.profile?.first_name || "";
|
||||
const preferredName = user?.profile?.username || "";
|
||||
const userEmail = user?.email || "";
|
||||
const displayName = preferredName || userEmail?.split("@")[0] || "User";
|
||||
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 { ImageIcon, Loader2 } from "lucide-react";
|
||||
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
|
||||
const profileFormSchema = z.object({
|
||||
|
@ -45,121 +48,120 @@ interface ProfileFormProps {
|
|||
}
|
||||
|
||||
export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(
|
||||
user?.profile?.avatar || null
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const supabase = createClient();
|
||||
// const [isLoading, setIsLoading] = useState(false);
|
||||
// const [avatarPreview, setAvatarPreview] = useState<string | null>(
|
||||
// user?.profile?.avatar || null
|
||||
// );
|
||||
|
||||
// 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 lastName = user?.profile?.last_name || "";
|
||||
const userEmail = user?.email || "";
|
||||
const userBio = user?.profile?.bio || "";
|
||||
const email = user?.email || "";
|
||||
const username = user?.profile?.username || "";
|
||||
|
||||
const getFullName = () => {
|
||||
return `${firstName} ${lastName}`.trim() || "User";
|
||||
};
|
||||
|
||||
// Generate initials for avatar fallback
|
||||
const getInitials = () => {
|
||||
if (firstName && lastName) {
|
||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
const {
|
||||
isPending,
|
||||
avatarPreview,
|
||||
fileInputRef,
|
||||
form,
|
||||
handleFileChange,
|
||||
handleAvatarClick,
|
||||
onSubmit,
|
||||
} = useProfileFormHandlers({ user, onSuccess })
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
|
@ -172,14 +174,14 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
|||
>
|
||||
<Avatar className="h-24 w-24 border-2 border-border">
|
||||
{avatarPreview ? (
|
||||
<AvatarImage src={avatarPreview} alt={getFullName()} />
|
||||
<AvatarImage src={avatarPreview} alt={username} />
|
||||
) : (
|
||||
<AvatarFallback className="text-2xl">
|
||||
{getInitials()}
|
||||
{getInitials(firstName, lastName, email)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
<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" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-white" />
|
||||
|
@ -190,10 +192,10 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept=""
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="avatar-upload"
|
||||
|
@ -264,7 +266,7 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
|||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || form.formState.isSubmitting}
|
||||
disabled={isPending || form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
|
|
|
@ -32,16 +32,10 @@ import {
|
|||
Copy,
|
||||
Loader2,
|
||||
} 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 { formatDate } from "@/app/_utils/common";
|
||||
import { useUserDetailSheetHandlers } from "../_handlers/use-detail-sheet";
|
||||
import { CAlertDialog } from "@/app/_components/alert-dialog";
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
open: boolean;
|
||||
|
@ -72,7 +66,7 @@ export function UserDetailSheet({
|
|||
|
||||
return (
|
||||
<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">
|
||||
<SheetTitle className="text-xl flex items-center gap-2">
|
||||
{user.email}
|
||||
|
@ -80,7 +74,7 @@ export function UserDetailSheet({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4"
|
||||
onClick={() => handleCopyItem(user.email ?? "")}
|
||||
onClick={() => handleCopyItem(user.email ?? "", "Email")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -108,7 +102,7 @@ export function UserDetailSheet({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 ml-2"
|
||||
onClick={() => handleCopyItem(user.id)}
|
||||
onClick={() => handleCopyItem(user.id, "UID")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -172,7 +166,7 @@ export function UserDetailSheet({
|
|||
<div>
|
||||
<div className="font-medium">Email</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Signed in with a email account via OAuth
|
||||
Signed in with a email account
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -290,56 +284,18 @@ export function UserDetailSheet({
|
|||
User will no longer have access to the project
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
{isDeletePending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<CAlertDialog
|
||||
triggerText="Delete user"
|
||||
triggerIcon={<Trash2 className="h-4 w-4" />}
|
||||
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={handleDeleteUser}
|
||||
isPending={isDeletePending}
|
||||
pendingText="Deleting..."
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -151,7 +151,11 @@ export default function UserManagement() {
|
|||
onOpenChange={setIsAddUserOpen}
|
||||
onUserAdded={() => { }}
|
||||
/>
|
||||
<InviteUserDialog open={isInviteUserOpen} onOpenChange={setIsInviteUserOpen} onUserInvited={() => refetch()} />
|
||||
<InviteUserDialog
|
||||
open={isInviteUserOpen}
|
||||
onOpenChange={setIsInviteUserOpen}
|
||||
onUserInvited={() => { }}
|
||||
/>
|
||||
{updateUser && (
|
||||
<UserProfileSheet
|
||||
open={isUpdateOpen}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import type { ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
|
@ -16,7 +15,11 @@ import { Input } from "@/app/_components/ui/input"
|
|||
import { Avatar } from "@/app/_components/ui/avatar"
|
||||
import Image from "next/image"
|
||||
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>
|
||||
|
||||
|
@ -25,10 +28,26 @@ export const createUserColumns = (
|
|||
setFilters: (filters: IUserFilterOptionsSchema) => void,
|
||||
handleUserUpdate: (user: IUserSchema) => void,
|
||||
): UserTableColumn[] => {
|
||||
|
||||
const { mutateAsync: deleteUser } = useDeleteUserMutation();
|
||||
const { mutateAsync: banUser } = useBanUserMutation();
|
||||
const { mutateAsync: unbanUser } = useUnbanUserMutation();
|
||||
const {
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
userToDelete,
|
||||
setUserToDelete,
|
||||
handleDeleteConfirm,
|
||||
isDeletePending,
|
||||
banDialogOpen,
|
||||
setBanDialogOpen,
|
||||
userToBan,
|
||||
setUserToBan,
|
||||
handleBanConfirm,
|
||||
unbanDialogOpen,
|
||||
setUnbanDialogOpen,
|
||||
userToUnban,
|
||||
setUserToUnban,
|
||||
isBanPending,
|
||||
isUnbanPending,
|
||||
handleUnbanConfirm,
|
||||
} = useCreateUserColumn()
|
||||
|
||||
return [
|
||||
{
|
||||
|
@ -319,7 +338,10 @@ export const createUserColumns = (
|
|||
Update
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteUser(row.original.id)}
|
||||
onClick={() => {
|
||||
setUserToDelete(row.original.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2 text-destructive" />
|
||||
Delete
|
||||
|
@ -327,9 +349,11 @@ export const createUserColumns = (
|
|||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (row.original.banned_until != null) {
|
||||
unbanUser({ id: row.original.id })
|
||||
setUserToUnban(row.original.id)
|
||||
setUnbanDialogOpen(true)
|
||||
} else {
|
||||
banUser({ id: row.original.id, ban_duration: "24h" })
|
||||
setUserToBan(row.original.id)
|
||||
setBanDialogOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -338,6 +362,50 @@ export const createUserColumns = (
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -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 { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app/(pages)/(auth)/_queries/mutations";
|
||||
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";
|
||||
|
||||
export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: {
|
||||
|
@ -24,30 +24,54 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
|||
const handleDeleteUser = async () => {
|
||||
await deleteUser(user.id, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast.success(`${user.email} has been deleted`);
|
||||
|
||||
onOpenChange(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendPasswordRecovery = async () => {
|
||||
if (!user.email) {
|
||||
toast.error("User has no email address");
|
||||
return;
|
||||
}
|
||||
await sendPasswordRecovery(user.email);
|
||||
};
|
||||
if (user.email) {
|
||||
await sendPasswordRecovery(user.email, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Password recovery email sent to ${user.email}`);
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
onOpenChange(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const handleSendMagicLink = async () => {
|
||||
if (!user.email) {
|
||||
toast.error("User has no email address");
|
||||
return;
|
||||
if (user.email) {
|
||||
await sendMagicLink(user.email, {
|
||||
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") => {
|
||||
await banUser({ id: user.id, ban_duration: ban_duration }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast(`${user.email} has been banned`);
|
||||
|
||||
onUserUpdated();
|
||||
}
|
||||
});
|
||||
|
@ -55,7 +79,12 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
|||
|
||||
const handleUnbanUser = async () => {
|
||||
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 {
|
||||
handleDeleteUser,
|
||||
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 { useForm } from "react-hook-form";
|
||||
import { useUpdateUserMutation } from "../_queries/mutations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: {
|
||||
open: boolean;
|
||||
|
@ -11,6 +13,8 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs
|
|||
onUserUpdated: () => void;
|
||||
}) => {
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
mutateAsync: updateUser,
|
||||
isPending,
|
||||
|
@ -54,10 +58,18 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs
|
|||
const handleUpdateUser = async () => {
|
||||
await updateUser({ id: userData.id, data: form.getValues() }, {
|
||||
onSuccess: () => {
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
|
||||
toast.success("User updated successfully")
|
||||
|
||||
onUserUpdated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
toast.error("Failed to update user")
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
|
@ -1,6 +1,6 @@
|
|||
import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model";
|
||||
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 { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-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),
|
||||
})
|
||||
}
|
||||
|
||||
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> */}
|
||||
<div className="flex flex-col max-w-full p-0">
|
||||
{children}
|
||||
<Toaster theme="system" position="top-right" />
|
||||
<Toaster theme="system" position="top-right" richColors />
|
||||
</div>
|
||||
|
||||
{/* <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
|
||||
static readonly PHONE_MIN_LENGTH = 10;
|
||||
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
|
||||
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);
|
||||
};
|
||||
|
||||
export const handleCopyItem = (item: string, options?: {
|
||||
export const copyItem = (item: string, options?: {
|
||||
label?: string,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
|
@ -140,7 +141,8 @@ export const handleCopyItem = (item: string, options?: {
|
|||
|
||||
navigator.clipboard.writeText(item)
|
||||
.then(() => {
|
||||
toast.success("Copied to clipboard");
|
||||
const label = options?.label || item;
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
options?.onSuccess?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -148,3 +150,154 @@ export const handleCopyItem = (item: string, options?: {
|
|||
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
|
||||
]);
|
||||
|
||||
usersModule
|
||||
.bind(DI_SYMBOLS.IUploadAvatarUseCase)
|
||||
.toHigherOrderFunction(updateUserUseCase, [
|
||||
DI_SYMBOLS.IInstrumentationService,
|
||||
DI_SYMBOLS.IUsersRepository
|
||||
]);
|
||||
|
||||
// Controllers
|
||||
usersModule
|
||||
.bind(DI_SYMBOLS.IBanUserController)
|
||||
|
@ -208,6 +215,13 @@ export function createUsersModule() {
|
|||
DI_SYMBOLS.IGetCurrentUserUseCase
|
||||
]);
|
||||
|
||||
usersModule
|
||||
.bind(DI_SYMBOLS.IUploadAvatarController)
|
||||
.toHigherOrderFunction(updateUserController, [
|
||||
DI_SYMBOLS.IInstrumentationService,
|
||||
DI_SYMBOLS.IUploadAvatarUseCase,
|
||||
]);
|
||||
|
||||
|
||||
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 { ISendMagicLinkController } from '@/src/interface-adapters/controllers/auth/send-magic-link.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 = {
|
||||
// Services
|
||||
|
@ -67,6 +69,7 @@ export const DI_SYMBOLS = {
|
|||
ICreateUserUseCase: Symbol.for('ICreateUserUseCase'),
|
||||
IUpdateUserUseCase: Symbol.for('IUpdateUserUseCase'),
|
||||
IDeleteUserUseCase: Symbol.for('IDeleteUserUseCase'),
|
||||
IUploadAvatarUseCase: Symbol.for('IUploadAvatarUseCase'),
|
||||
|
||||
// Controllers
|
||||
ISignInController: Symbol.for('ISignInController'),
|
||||
|
@ -86,6 +89,7 @@ export const DI_SYMBOLS = {
|
|||
ICreateUserController: Symbol.for('ICreateUserController'),
|
||||
IUpdateUserController: Symbol.for('IUpdateUserController'),
|
||||
IDeleteUserController: Symbol.for('IDeleteUserController'),
|
||||
IUploadAvatarController: Symbol.for('IUploadAvatarController'),
|
||||
};
|
||||
|
||||
export interface DI_RETURN_TYPES {
|
||||
|
@ -117,6 +121,7 @@ export interface DI_RETURN_TYPES {
|
|||
ICreateUserUseCase: ICreateUserUseCase;
|
||||
IUpdateUserUseCase: IUpdateUserUseCase;
|
||||
IDeleteUserUseCase: IDeleteUserUseCase;
|
||||
IUploadAvatarUseCase: IUploadAvatarUseCase;
|
||||
|
||||
// Controllers
|
||||
ISignInController: ISignInController;
|
||||
|
@ -136,5 +141,6 @@ export interface DI_RETURN_TYPES {
|
|||
ICreateUserController: ICreateUserController;
|
||||
IUpdateUserController: IUpdateUserController;
|
||||
IDeleteUserController: IDeleteUserController;
|
||||
IUploadAvatarController: IUploadAvatarController;
|
||||
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@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-select": "^2.1.6",
|
||||
"@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": {
|
||||
"version": "1.1.2",
|
||||
"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-label": "^2.1.2",
|
||||
"@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-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
|
|
|
@ -22,4 +22,5 @@ export interface IUsersRepository {
|
|||
deleteUser(credential: ICredentialsDeleteUserSchema, tx?: ITransaction): Promise<IUserSchema>;
|
||||
banUser(credential: ICredentialsBanUserSchema, input: IBanUserSchema, 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");
|
||||
}
|
||||
|
||||
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) {
|
||||
this.crashReporterService.report(err);
|
||||
throw err;
|
||||
|
@ -289,11 +308,10 @@ export class UsersRepository implements IUsersRepository {
|
|||
const queryUpdateSupabaseUser = supabase.auth.admin.updateUserById(credential.id, {
|
||||
email: input.email,
|
||||
email_confirm: input.email_confirmed_at,
|
||||
password: input.encrypted_password ?? undefined,
|
||||
password_hash: input.encrypted_password ?? undefined,
|
||||
password: input.encrypted_password,
|
||||
password_hash: input.encrypted_password,
|
||||
phone: input.phone,
|
||||
phone_confirm: input.phone_confirmed_at,
|
||||
role: input.role,
|
||||
user_metadata: input.user_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