refactor createUsersTable

This commit is contained in:
vergiLgood1 2025-03-29 21:34:48 +07:00
parent 2faf6ce83e
commit 9380c371f8
33 changed files with 1375 additions and 296 deletions

View File

@ -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>

View File

@ -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

View File

@ -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 || "";

View File

@ -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>
)
}

View File

@ -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 ? (
<>

View File

@ -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>

View File

@ -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}

View File

@ -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>
),
},

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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);
},
});

View File

@ -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),
})
}

View 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.');
}
}
);
}

View File

@ -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">

View File

@ -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>
)
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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"];
}

View File

@ -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";
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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",

View File

@ -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",

View File

@ -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>;
}

View File

@ -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;
}
);
};

View File

@ -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;
}
})
}
}

View File

@ -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);
});
};