diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx index 545555c..a5c8d8d 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx @@ -1,4 +1,4 @@ -// components/user-management/sheet/user-information-sheet.tsx +// components/selectedUser-management/sheet/selectedUser-information-sheet.tsx import { useState } from "react"; import { Sheet, @@ -19,26 +19,26 @@ import { UserOverviewTab } from "../tabs/user-overview-tab"; interface UserInformationSheetProps { open: boolean; - user: IUserSchema; + selectedUser: IUserSchema; onOpenChange: (open: boolean) => void; } export function UserInformationSheet({ open, onOpenChange, - user, + selectedUser, }: UserInformationSheetProps) { const [activeTab, setActiveTab] = useState("overview"); const { handleCopyItem, - } = useUserDetailSheetHandlers({ open, user, onOpenChange }); + } = useUserDetailSheetHandlers({ open, selectedUser, onOpenChange }); const getUserStatusBadge = () => { - if (user.banned_until) { + if (selectedUser.banned_until) { return Banned; } - if (!user.email_confirmed_at) { + if (!selectedUser.email_confirmed_at) { return Unconfirmed; } return Active; @@ -49,12 +49,12 @@ export function UserInformationSheet({ - {user.email} + {selectedUser.email} @@ -76,17 +76,17 @@ export function UserInformationSheet({ - + - + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx index 2e1e373..7a0e433 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx @@ -8,6 +8,7 @@ import { ActionsCell } from "../cells/actions-cell" export const createActionsColumn = ( handleUserUpdate: (user: IUserSchema) => void ) => { + return { id: "actions", header: () => { diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts index 3216fc7..ab11ad2 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts @@ -1,6 +1,5 @@ // columns/index.ts - import type { ColumnDef } from "@tanstack/react-table" import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model" import { createEmailColumn } from "./email-column" @@ -9,6 +8,9 @@ import { createCreatedAtColumn } from "./created-at-column" import { createStatusColumn } from "./status-column" import { createActionsColumn } from "./actions-column" import { createLastSignInColumn } from "./last-sign-in-column" +import { useGetCurrentUserQuery } from "../../../_queries/queries" +import { USER_ROLES } from "@/app/_utils/const/roles" +import { AuthenticationError } from "@/src/entities/errors/auth" export type UserTableColumn = ColumnDef @@ -16,13 +18,36 @@ export const createUserColumns = ( filters: IUserFilterOptionsSchema, setFilters: (filters: IUserFilterOptionsSchema) => void, handleUserUpdate: (user: IUserSchema) => void, + currentUser?: IUserSchema, ): UserTableColumn[] => { + + // Check if the user is an admin + // const { data: user, isLoading, isError, error } = useGetCurrentUserQuery(); + + // console.log("Query Status:", { isLoading, isError, error, user }); + + if (!currentUser || !currentUser.role) { + return [ + createEmailColumn(filters, setFilters), + createPhoneColumn(filters, setFilters), + createLastSignInColumn(filters, setFilters), + createCreatedAtColumn(filters, setFilters), + createStatusColumn(filters, setFilters), + // Kolom actions tidak disertakan karena memerlukan role + ] + } + + const allowedRoles = USER_ROLES.ALLOWED_ROLES_TO_ACTIONS + let isAllowed = allowedRoles.includes(currentUser.role.name) + + console.log("User Role:", currentUser.role.name, "Allowed Roles:", allowedRoles, "Is Allowed:", isAllowed) + return [ createEmailColumn(filters, setFilters), createPhoneColumn(filters, setFilters), createLastSignInColumn(filters, setFilters), createCreatedAtColumn(filters, setFilters), createStatusColumn(filters, setFilters), - createActionsColumn(handleUserUpdate), + ...(isAllowed ? [createActionsColumn(handleUserUpdate)] : []), ] } \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx index ef27021..9a2a880 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx @@ -1,4 +1,4 @@ -// components/user-management/sheet/tabs/user-details-tab.tsx +// components/selectedUser-management/sheet/tabs/selectedUser-details-tab.tsx import { IUserSchema } from "@/src/entities/models/users/users.model"; import { formatDate } from "@/app/_utils/common"; import { Separator } from "@/app/_components/ui/separator"; @@ -8,10 +8,10 @@ import { Edit2, Eye, EyeOff } from "lucide-react"; import { useState } from "react"; interface UserDetailsTabProps { - user: IUserSchema; + selectedUser: IUserSchema; } -export function UserDetailsTab({ user }: UserDetailsTabProps) { +export function UserDetailsTab({ selectedUser }: UserDetailsTabProps) { const [showSensitiveInfo, setShowSensitiveInfo] = useState(false); const toggleSensitiveInfo = () => { @@ -23,7 +23,7 @@ export function UserDetailsTab({ user }: UserDetailsTabProps) { {/* Basic Information */}
-

User Details

+

SelectedUser Details

-
-
- -
- Created at - {formatDate(user.created_at)} -
- -
- Last signed in - {formatDate(user.last_sign_in_at)} -
- -
- SSO - -
- - +
+ memoizedHandleCopyItem(selectedUser.id, "UID")} + /> + + + } + /> +
{/* Provider Information Section */} -
-

Provider Information

-

- The user has the following providers -

- -
-
-
- -
-
Email
-
- Signed in with a email account -
-
-
+
+ } + title="Email" + description="Signed in with an email account" + badge={ Enabled -
-
+ } + /> -
-
-
-

Reset password

-

- Send a password recovery email to the user -

-
- -
+ {isAllowedToSendEmail && ( + + )} - + {isAllowedToSendEmail && ( +
+ {isAllowedToSendPasswordRecovery && ( + } + actionText="Send password recovery" + buttonVariant="outline" + buttonClassName="bg-secondary/80 border-secondary-foreground/20 hover:border-secondary-foreground/30" + /> + )} -
-
-

Send magic link

-

- Passwordless login via email for the user -

-
- + {!isAllowedToSendMagicLink && } + + {!isAllowedToSendMagicLink && ( + } + actionText="Send magic link" + buttonVariant="outline" + buttonClassName="bg-secondary/80 border-secondary-foreground/20 hover:border-secondary-foreground/30" + /> + )}
-
-
+ )} + {/* Danger Zone Section */} -
-

- Danger zone -

-

- Be wary of the following features as they cannot be undone. -

- -
-
-
-

Ban user

-

- Revoke access to the project for a set duration -

-
- -
- -
-
-

Delete user

-

- User will no longer have access to the project -

-
- } - 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" + {isAllowedToDelete && ( +
+ {selectedUser.banned_until ? ( + } + actionText="Unban user" + className="rounded-b-none" + buttonVariant="outline" /> -
-
-
+ ) : ( + , + title: "Select ban duration", + description: + "The user will not be able to access the project for the selected duration.", + confirmText: "Ban", + onConfirm: handleToggleBan, + isPending: isBanPending, + pendingText: "Banning...", + variant: "outline", + size: "sm", + }} + /> + )} + + , + 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", + }} + /> + + )}
); -} \ No newline at end of file +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx index 4856947..fdf33bf 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx @@ -4,7 +4,7 @@ import { useState, useMemo, useEffect } from "react"; import { DataTable } from "../../../../../_components/data-table"; import { UserInformationSheet } from "./sheets/user-information-sheet"; -import { useGetUsersQuery } from "../_queries/queries"; +import { useGetCurrentUserQuery, useGetUsersQuery } from "../_queries/queries"; import { filterUsers, useUserManagementHandlers } from "../_handlers/use-user-management"; import { useAddUserDialogHandler } from "../_handlers/use-add-user-dialog"; @@ -26,6 +26,8 @@ export default function UserManagement() { refetch, } = useGetUsersQuery(); + const { data: currentUser } = useGetCurrentUserQuery() + // User management handler const { searchQuery, @@ -72,6 +74,7 @@ export default function UserManagement() { filters, setFilters, handleUserUpdate, + currentUser ) // State untuk jumlah data di halaman saat ini @@ -99,7 +102,7 @@ export default function UserManagement() { {isDetailUser && ( diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts index bd3aa65..dddd7a2 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts @@ -7,9 +7,9 @@ import { copyItem } from "@/app/_utils/common"; import { useQueryClient } from "@tanstack/react-query"; import { useUserActionsHandler } from "./actions/use-user-actions"; -export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { +export const useUserDetailSheetHandlers = ({ open, selectedUser, onOpenChange }: { open: boolean; - user: IUserSchema; + selectedUser: IUserSchema; onOpenChange: (open: boolean) => void; }) => { @@ -22,10 +22,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { const { mutateAsync: unbanUser, isPending: isUnbanPending } = useUnbanUserMutation(); const handleDeleteUser = async () => { - await deleteUser(user.id, { + await deleteUser(selectedUser.id, { onSuccess: () => { invalidateUsers(); - toast.success(`${user.email} has been deleted`); + toast.success(`${selectedUser.email} has been deleted`); onOpenChange(false); } @@ -33,10 +33,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { }; const handleSendPasswordRecovery = async () => { - if (user.email) { - await sendPasswordRecovery(user.email, { + if (selectedUser.email) { + await sendPasswordRecovery(selectedUser.email, { onSuccess: () => { - toast.success(`Password recovery email sent to ${user.email}`); + toast.success(`Password recovery email sent to ${selectedUser.email}`); onOpenChange(false); }, @@ -50,10 +50,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { } const handleSendMagicLink = async () => { - if (user.email) { - await sendMagicLink(user.email, { + if (selectedUser.email) { + await sendMagicLink(selectedUser.email, { onSuccess: () => { - toast.success(`Magic link sent to ${user.email}`); + toast.success(`Magic link sent to ${selectedUser.email}`); onOpenChange(false); }, @@ -67,10 +67,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { }; const handleBanUser = async (ban_duration: ValidBanDuration = "24h") => { - await banUser({ id: user.id, ban_duration: ban_duration }, { + await banUser({ id: selectedUser.id, ban_duration: ban_duration }, { onSuccess: () => { invalidateUsers(); - toast(`${user.email} has been banned`); + toast(`${selectedUser.email} has been banned`); } @@ -78,10 +78,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { }; const handleUnbanUser = async () => { - await unbanUser({ id: user.id }, { + await unbanUser({ id: selectedUser.id }, { onSuccess: () => { invalidateUsers(); - toast(`${user.email} has been unbanned`); + toast(`${selectedUser.email} has been unbanned`); } @@ -89,21 +89,21 @@ export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: { }; const handleToggleBan = async (ban_duration: ValidBanDuration = "24h") => { - if (user.banned_until) { - await unbanUser({ id: user.id }, { + if (selectedUser.banned_until) { + await unbanUser({ id: selectedUser.id }, { onSuccess: () => { invalidateUsers(); - toast(`${user.email} has been unbanned`); + toast(`${selectedUser.email} has been unbanned`); } }); } else { - await banUser({ id: user.id, ban_duration: ban_duration }, { + await banUser({ id: selectedUser.id, ban_duration: ban_duration }, { onSuccess: () => { invalidateUsers(); - toast(`${user.email} has been banned`); + toast(`${selectedUser.email} has been banned`); } }); diff --git a/sigap-website/app/(pages)/(auth)/_handlers/use-check-permissions.ts b/sigap-website/app/(pages)/(auth)/_handlers/use-check-permissions.ts index e69de29..5694d6e 100644 --- a/sigap-website/app/(pages)/(auth)/_handlers/use-check-permissions.ts +++ b/sigap-website/app/(pages)/(auth)/_handlers/use-check-permissions.ts @@ -0,0 +1,22 @@ +import { useCheckPermissionsQuery } from "../_queries/queries" + +export const useCheckPermissionsHandler = (email: string) => { + + const { data: isAllowedToCreate } = useCheckPermissionsQuery(email, "users", "create") + const { data: isAllowedToUpdate } = useCheckPermissionsQuery(email, "users", "update") + const { data: isAllowedToDelete } = useCheckPermissionsQuery(email, "users", "delete") + + const { data: isAllowedToBan } = useCheckPermissionsQuery(email, "users", "ban") + const { data: isAllowedToSendPasswordRecovery } = useCheckPermissionsQuery(email, "users", "send_password_recovery",) + const { data: isAllowedToSendMagicLink } = useCheckPermissionsQuery(email, "users", "send_magic_link") + + return { + isAllowedToCreate, + isAllowedToUpdate, + isAllowedToDelete, + isAllowedToBan, + isAllowedToSendPasswordRecovery, + isAllowedToSendMagicLink, + isAllowedToSendEmail: isAllowedToSendPasswordRecovery || isAllowedToSendMagicLink, + } +} \ No newline at end of file diff --git a/sigap-website/app/_components/action-row.tsx b/sigap-website/app/_components/action-row.tsx new file mode 100644 index 0000000..4b559de --- /dev/null +++ b/sigap-website/app/_components/action-row.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from "react"; +import { Button } from "@/app/_components/ui/button"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/app/_lib/utils"; + +interface ActionRowProps { + title: string; + description: string; + onClick: () => void; + isPending?: boolean; + pendingText?: string; + icon?: ReactNode; + actionText: string; + className?: string; + contentClassName?: string; + titleClassName?: string; + descriptionClassName?: string; + buttonClassName?: string; + buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + buttonSize?: "default" | "sm" | "lg" | "icon"; +} + +export function ActionRow({ + title, + description, + onClick, + isPending = false, + pendingText = "Loading...", + icon, + actionText, + className, + contentClassName, + titleClassName, + descriptionClassName, + buttonClassName, + buttonVariant = "outline", + buttonSize = "sm" +}: ActionRowProps) { + return ( +
+
+

{title}

+

{description}

+
+ +
+ ); +} diff --git a/sigap-website/app/_components/danger-action.tsx b/sigap-website/app/_components/danger-action.tsx new file mode 100644 index 0000000..7a27d9f --- /dev/null +++ b/sigap-website/app/_components/danger-action.tsx @@ -0,0 +1,79 @@ +import { ReactNode } from "react"; +import { Button } from "@/app/_components/ui/button"; +import { Loader2 } from "lucide-react"; +import { CAlertDialog } from "@/app/_components/alert-dialog"; +import { cn } from "@/app/_lib/utils"; + +interface DangerActionProps { + title: string; + description: string; + onClick?: () => void; + isPending?: boolean; + pendingText?: string; + icon?: ReactNode; + actionText?: string; + isDialog?: boolean; + dialogProps?: any; + className?: string; + contentClassName?: string; + titleClassName?: string; + descriptionClassName?: string; + buttonClassName?: string; + containerClassName?: string; + buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + buttonSize?: "default" | "sm" | "lg" | "icon"; +} + +export function DangerAction({ + title, + description, + onClick, + isPending = false, + pendingText = "Loading...", + icon, + actionText, + isDialog, + dialogProps, + className, + contentClassName, + titleClassName, + descriptionClassName, + buttonClassName, + containerClassName, + buttonVariant = "outline", + buttonSize = "sm" +}: DangerActionProps) { + return ( +
+
+

{title}

+

{description}

+
+ {isDialog ? ( +
+ +
+ ) : ( + + )} +
+ ); +} diff --git a/sigap-website/app/_components/info-row.tsx b/sigap-website/app/_components/info-row.tsx new file mode 100644 index 0000000..10e4e29 --- /dev/null +++ b/sigap-website/app/_components/info-row.tsx @@ -0,0 +1,45 @@ +import { ReactNode } from "react"; +import { Button } from "@/app/_components/ui/button"; +import { Copy } from "lucide-react"; +import { cn } from "@/app/_lib/utils"; + +interface InfoRowProps { + label: string; + value: ReactNode; + onCopy?: () => void; + className?: string; + labelClassName?: string; + valueClassName?: string; + copyButtonClassName?: string; +} + +export function InfoRow({ + label, + value, + onCopy, + className, + labelClassName, + valueClassName, + copyButtonClassName +}: InfoRowProps) { + return ( +
+ {label} +
+ {typeof value === "string" ? ( + {value} + ) : value} + {onCopy && ( + + )} +
+
+ ); +} diff --git a/sigap-website/app/_components/provider-info.tsx b/sigap-website/app/_components/provider-info.tsx new file mode 100644 index 0000000..7d08ff0 --- /dev/null +++ b/sigap-website/app/_components/provider-info.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from "react"; +import { cn } from "@/app/_lib/utils"; + +interface ProviderInfoProps { + icon: ReactNode; + title: string; + description: string; + badge: ReactNode; + className?: string; + containerClassName?: string; + headerClassName?: string; + titleContainerClassName?: string; + titleClassName?: string; + descriptionClassName?: string; + badgeClassName?: string; +} + +export function ProviderInfo({ + icon, + title, + description, + badge, + className, + containerClassName, + headerClassName, + titleContainerClassName, + titleClassName, + descriptionClassName, + badgeClassName +}: ProviderInfoProps) { + return ( +
+
+
+ {icon} +
+
{title}
+
{description}
+
+
+
+ {badge} +
+
+
+ ); +} diff --git a/sigap-website/app/_components/section.tsx b/sigap-website/app/_components/section.tsx new file mode 100644 index 0000000..bd8c47a --- /dev/null +++ b/sigap-website/app/_components/section.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from "react"; +import { cn } from "@/app/_lib/utils"; + +interface SectionProps { + title: string; + description?: string; + children: ReactNode; + className?: string; + titleClassName?: string; + descriptionClassName?: string; + contentClassName?: string; +} + +export function Section({ + title, + description, + children, + className, + titleClassName, + descriptionClassName, + contentClassName +}: SectionProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+
+ {children} +
+
+ ); +} diff --git a/sigap-website/app/_styles/globals.css b/sigap-website/app/_styles/globals.css index f3d43b9..ff7bfeb 100644 --- a/sigap-website/app/_styles/globals.css +++ b/sigap-website/app/_styles/globals.css @@ -84,6 +84,13 @@ /* Sekunder: abu-abu gelap untuk elemen pendukung */ --secondary: 0 0% 15%; /* #262626 */ --secondary-foreground: 0 0% 85%; /* #d9d9d9 */ + --secondary-tertiary: #242424; /* #242424 */ + + /* Third */ + --tertiary: 0 0% 12%; /* #1F1F1F1 */ + --tertiary-foreground: 0 0% 85%; /* #d9d9d9 */ + --tertiary-border: 0 0% 20%; /* #333333 */ + /* Muted: abu-abu gelap untuk teks pendukung */ --muted: 0 0% 20%; /* #333333 */ diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index 7456ff1..0e32710 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -278,8 +278,8 @@ export const formatDateWithLocaleAndFallback = ( * @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"; +export const getFullName = (firstName: string | null | undefined, lastName: string | null | undefined): string => { + return `${firstName || ""} ${lastName || ""}`.trim() || "User"; } /** diff --git a/sigap-website/app/_utils/const/roles.ts b/sigap-website/app/_utils/const/roles.ts new file mode 100644 index 0000000..b04e98b --- /dev/null +++ b/sigap-website/app/_utils/const/roles.ts @@ -0,0 +1,16 @@ +import { IUserRoles } from "@/src/entities/models/roles/roles.model"; + +export const USER_ROLES = { + + TYPES: { + ADMIN: 'admin', + STAFF: 'staff', + VIEWER: 'viewer', + }, + + ALLOWED_ROLES_TO_ACTIONS: [ + "admin", + "staff" + ] + +}; diff --git a/sigap-website/src/entities/models/roles/roles.model.ts b/sigap-website/src/entities/models/roles/roles.model.ts index 90d81d2..94dd10b 100644 --- a/sigap-website/src/entities/models/roles/roles.model.ts +++ b/sigap-website/src/entities/models/roles/roles.model.ts @@ -1,5 +1,18 @@ import { z } from "zod"; +export type IUserRoles = { + TYPES: { + ADMIN: 'admin'; + STAFF: 'staff'; + VIEWER: 'viewer'; + }; + + ALLOWED_ROLES_TO_ACTIONS: [ + 'admin', + 'staff' + ]; +}; + export const RoleSchema = z.object({ id: z.string(), name: z.string(),