diff --git a/messages/en.json b/messages/en.json index 05ca731..9867e22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -55,6 +55,8 @@ "ui_save_btn": "Save changes", "ui_update_btn": "Update", "ui_delete_btn": "Delete", + "ui_leave_btn": "Leave", + "ui_remove_btn": "Remove", "ui_ban_btn": "Lock", "ui_unban_btn": "Unlock", "ui_invite_btn": "Invite", @@ -174,6 +176,12 @@ "houses_page_form_user_select_search_placeholder": "Search by name or email...", "houses_page_ui_dialog_alert_delete_title": "Delete house: {name}?", "houses_page_ui_dialog_alert_delete_description": "This action cannot be undone! It will delete all related data like: Box, Item. Please think carefully!", + "houses_page_ui_dialog_alert_leave_title": "You want to leave this house?", + "houses_page_ui_dialog_alert_leave_description": "When you leave, you should contact the owner if you want to join back! Are you sure?", + "houses_page_ui_dialog_alert_remove_title": "Do you want to remove {name} from this house?", + "houses_page_ui_dialog_alert_remove_description": "Once removed, you must invite them again and wait for the member to accept!", + "houses_page_message_leave_house_success": "Leaved house successfully!", + "houses_page_message_remove_member_success": "Removed member successfully!", "houses_page_message_create_house_success": "Created house successfully!", "houses_page_message_house_not_found": "House not found!", "houses_page_message_update_house_success": "Updated house successfully!", diff --git a/messages/vi.json b/messages/vi.json index eaf4c0f..72a726b 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -28,7 +28,7 @@ "role=admin": "Quản lý", "role=user": "Người dùng", "role=member": "Thành viên", - "role=owner": "Người sở hữu" + "role=owner": "Chủ nhà" } } ], @@ -55,6 +55,8 @@ "ui_save_btn": "Lưu thay đổi", "ui_update_btn": "Cập nhật", "ui_delete_btn": "Xóa", + "ui_leave_btn": "Rời đi", + "ui_remove_btn": "Xóa", "ui_ban_btn": "Khóa", "ui_unban_btn": "Mở khóa", "ui_invite_btn": "Mời", @@ -178,6 +180,12 @@ "houses_page_form_user_select_search_placeholder": "Tìm theo tên hoặc email...", "houses_page_ui_dialog_alert_delete_title": "Bạn muốn xóa nhà này: {name}?", "houses_page_ui_dialog_alert_delete_description": "Thao tác này không thể hoàn tác! Nó sẽ xóa hết mọi dữ liệu liên quan như: Hộp chứa, Vật Phẩm. Xin suy tính kỹ lưỡng!", + "houses_page_ui_dialog_alert_leave_title": "Bạn muốn rời khỏi nhà này?", + "houses_page_ui_dialog_alert_leave_description": "Một khi rời khỏi, bạn phải liên hệ chủ nhà nếu muốn được thêm vào lại! Bạn chắc chắn muốn rời khỏi nhà này?", + "houses_page_ui_dialog_alert_remove_title": "Bạn muốn xóa {name} khỏi nhà?", + "houses_page_ui_dialog_alert_remove_description": "Một khi bị xóa, bạn phải mời lại và đợi thành viên đồng ý!", + "houses_page_message_leave_house_success": "Rời khỏi nhà thành công!", + "houses_page_message_remove_member_success": "Xóa thành viên thành công!", "houses_page_message_create_house_success": "Tạo nhà thành công!", "houses_page_message_house_not_found": "Không tìm thấy nhà này!", "houses_page_message_update_house_success": "Cập nhật nhà thành công!", diff --git a/prisma/data.ts b/prisma/data.ts index 9e00b35..31f3cf8 100644 --- a/prisma/data.ts +++ b/prisma/data.ts @@ -22,7 +22,7 @@ export const userData = [ email: 'raysam024@gmail.com', }, { - name: 'Raysam', + name: 'Sam Miu', email: 'juines.liu@gmail.com', }, ]; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fe1b699..dae9734 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,7 +5,7 @@ import Notification from './Notification'; import RouterBreadcrumb from './sidebar/router-breadcrumb'; export default function Header() { - const { data: session } = useAuth(); + const { session } = useAuth(); return ( <> diff --git a/src/components/auth/auth-provider.tsx b/src/components/auth/auth-provider.tsx index 4175ff9..bbca018 100644 --- a/src/components/auth/auth-provider.tsx +++ b/src/components/auth/auth-provider.tsx @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { createContext, useContext, useMemo } from 'react'; export type UserContext = { - data: ClientSession; + session: ClientSession; isAuth: boolean; isAdmin: boolean; isPending: boolean; @@ -18,7 +18,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const contextSession: UserContext = useMemo( () => ({ - data: session as ClientSession, + session: session as ClientSession, isPending, error, isAuth: !!session, diff --git a/src/components/avatar/avatar-user.tsx b/src/components/avatar/avatar-user.tsx index 3cea76d..f3c03fc 100644 --- a/src/components/avatar/avatar-user.tsx +++ b/src/components/avatar/avatar-user.tsx @@ -9,7 +9,7 @@ interface AvatarUserProps { } const AvatarUser = ({ className, textSize = 'md' }: AvatarUserProps) => { - const { data: session } = useAuth(); + const { session } = useAuth(); const imagePath = session?.user?.image ? new URL(`../../../data/avatar/${session?.user?.image}`, import.meta.url) .href diff --git a/src/components/form/account/profile-form.tsx b/src/components/form/account/profile-form.tsx index 4e3a265..dd95e18 100644 --- a/src/components/form/account/profile-form.tsx +++ b/src/components/form/account/profile-form.tsx @@ -21,7 +21,7 @@ const defaultValues: ProfileInput = { const ProfileForm = () => { const fileInputRef = useRef(null); - const { data: session, isPending } = useAuth(); + const { session, isPending } = useAuth(); const queryClient = useQueryClient(); const { mutate: updateProfileMutation, isPending: isRunning } = useMutation({ diff --git a/src/components/form/house/admin-create-house-form.tsx b/src/components/form/house/admin-create-house-form.tsx index 9951e54..e345f55 100644 --- a/src/components/form/house/admin-create-house-form.tsx +++ b/src/components/form/house/admin-create-house-form.tsx @@ -21,7 +21,7 @@ type FormProps = { }; const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => { - const { data: session } = useAuth(); + const { session } = useAuth(); const [userKeyword, setUserKeyword] = useState(''); const debouncedUserKeyword = useDebounced(userKeyword, 300); const { data: users } = useQuery( diff --git a/src/components/house/create-house-dialog.tsx b/src/components/house/create-house-dialog.tsx index 096fb4b..ac8d09e 100644 --- a/src/components/house/create-house-dialog.tsx +++ b/src/components/house/create-house-dialog.tsx @@ -14,6 +14,7 @@ import { DialogTrigger, } from '@ui/dialog'; import { useState } from 'react'; +import { Skeleton } from '../ui/skeleton'; type CreateNewHouseProp = { isPersonal?: boolean; @@ -24,46 +25,42 @@ const CreateNewHouse = ({ className, isPersonal = false, }: CreateNewHouseProp) => { - const { hasPermission, isLoading } = useHasPermission( - 'house', - 'create', - isPersonal, - ); + const { hasPermission, isLoading } = useHasPermission('house', 'create'); const [_open, _setOpen] = useState(false); const prevent = usePreventAutoFocus(); - if (isLoading) return null; - - if (hasPermission) { - return ( - - - - - e.preventDefault()} - > - - - - {m.nav_add_new()} - - - {m.nav_add_new()} - - - - - - ); + if (isLoading) { + return ; } - return null; + if (!hasPermission) return null; + + return ( + + + + + e.preventDefault()} + > + + + + {m.nav_add_new()} + + + {m.nav_add_new()} + + + + + + ); }; export default CreateNewHouse; diff --git a/src/components/house/current-user-action-group.tsx b/src/components/house/current-user-action-group.tsx index 440b1a5..dd26d6a 100644 --- a/src/components/house/current-user-action-group.tsx +++ b/src/components/house/current-user-action-group.tsx @@ -1,10 +1,11 @@ import { authClient } from '@lib/auth-client'; import { m } from '@paraglide/messages'; -import { GearIcon, PenIcon } from '@phosphor-icons/react'; -import { Button } from '@ui/button'; +import { GearIcon } from '@phosphor-icons/react'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; +import { Skeleton } from '../ui/skeleton'; import DeleteUserHouseAction from './delete-user-house-dialog'; -import EditHouseAction from './edit-house-dialog'; +import EditUserHouseAction from './edit-user-house-dialog'; +import LeaveHouseAction from './leave-house-dialog'; type CurrentUserActionGroupProps = { oneHouse: boolean; @@ -15,6 +16,10 @@ const CurrentUserActionGroup = ({ oneHouse, activeHouse, }: CurrentUserActionGroupProps) => { + if (!activeHouse) { + return ; + } + return ( @@ -24,17 +29,9 @@ const CurrentUserActionGroup = ({ - - - + {!oneHouse && } + ); diff --git a/src/components/house/current-user-invitation-list.tsx b/src/components/house/current-user-invitation-list.tsx index 7aec2c8..1932f4e 100644 --- a/src/components/house/current-user-invitation-list.tsx +++ b/src/components/house/current-user-invitation-list.tsx @@ -53,8 +53,10 @@ const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => { cancelInvitationMutation({ data: { id } }); }; - if (!activeHouse) { - return ; + if (!activeHouse || isLoading) { + return ( + + ); } return ( diff --git a/src/components/house/current-user-member-list.tsx b/src/components/house/current-user-member-list.tsx index e0e74f5..6cf13c2 100644 --- a/src/components/house/current-user-member-list.tsx +++ b/src/components/house/current-user-member-list.tsx @@ -10,16 +10,20 @@ import { TableHeader, TableRow, } from '@ui/table'; +import { useAuth } from '../auth/auth-provider'; import RoleBadge from '../avatar/role-badge'; import InviteUserAction from './invite-user-dialog'; +import RemoveUserFormHouse from './remove-user-form-house'; type CurrentUserMemberListProps = { activeHouse: ReturnType['data']; }; const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => { + const { session } = useAuth(); + if (!activeHouse) { - return ; + return ; } return ( @@ -57,7 +61,10 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
- {activeHouse.color} + {member.role !== 'owner' && + session.user.id !== member.user.id && ( + + )}
diff --git a/src/components/house/delete-house-dialog.tsx b/src/components/house/delete-house-dialog.tsx index c8f6905..3139bcd 100644 --- a/src/components/house/delete-house-dialog.tsx +++ b/src/components/house/delete-house-dialog.tsx @@ -31,6 +31,7 @@ import parse from 'html-react-parser'; import { useState } from 'react'; import { toast } from 'sonner'; import RoleBadge from '../avatar/role-badge'; +import { Skeleton } from '../ui/skeleton'; type DeleteHouseProps = { data: HouseWithMembers; @@ -69,93 +70,93 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => { deleteHouseMutation({ data }); }; - if (isLoading) return null; - - if (hasPermission) { - return ( - - - - - - - - - - - - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - - -
- -
- {m.houses_page_ui_dialog_alert_delete_title({ name: data.name })} -
- - {parse(m.houses_page_ui_dialog_alert_delete_description())} - -
-
- - - - - {m.houses_page_ui_view_table_header_email()} - - - {m.houses_page_ui_view_table_header_role()} - - - - - {data.members.map((member) => ( - - {member.user.email} - - - - - ))} - -
-
- - - - - - -
-
- ); + if (isLoading) { + return ; } - return null; + if (!hasPermission) return null; + + return ( + + + + + + + + + + + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + +
+ +
+ {m.houses_page_ui_dialog_alert_delete_title({ name: data.name })} +
+ + {parse(m.houses_page_ui_dialog_alert_delete_description())} + +
+
+ + + + + {m.houses_page_ui_view_table_header_email()} + + + {m.houses_page_ui_view_table_header_role()} + + + + + {data.members.map((member) => ( + + {member.user.email} + + + + + ))} + +
+
+ + + + + + +
+
+ ); }; export default DeleteHouseAction; diff --git a/src/components/house/delete-user-house-dialog.tsx b/src/components/house/delete-user-house-dialog.tsx index 5d4be91..78cce41 100644 --- a/src/components/house/delete-user-house-dialog.tsx +++ b/src/components/house/delete-user-house-dialog.tsx @@ -32,6 +32,7 @@ import parse from 'html-react-parser'; import { useState } from 'react'; import { toast } from 'sonner'; import RoleBadge from '../avatar/role-badge'; +import { Skeleton } from '../ui/skeleton'; type DeleteUserHouseProps = { activeHouse: ReturnType['data']; @@ -40,7 +41,11 @@ type DeleteUserHouseProps = { const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => { const [_open, _setOpen] = useState(false); const prevent = usePreventAutoFocus(); - const { hasPermission, isLoading } = useHasPermission('house', 'delete'); + const { hasPermission, isLoading } = useHasPermission( + 'house', + 'delete', + true, + ); const queryClient = useQueryClient(); @@ -66,98 +71,98 @@ const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => { }, }); - if (isLoading || !activeHouse) return null; + if (isLoading || !activeHouse) { + return ; + } const onConfirm = async () => { deleteHouseMutation({ data: { id: activeHouse.id } }); }; - if (hasPermission) { - return ( - - - - - - - - - - - - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - - -
- -
- {m.houses_page_ui_dialog_alert_delete_title({ - name: activeHouse.name, - })} -
- - {parse(m.houses_page_ui_dialog_alert_delete_description())} - -
-
- - - - - {m.houses_page_ui_view_table_header_email()} - - - {m.houses_page_ui_view_table_header_role()} - - - - - {activeHouse.members.map((member) => ( - - {member.user.email} - - - - - ))} - -
-
- - - - - - -
-
- ); - } + if (!hasPermission) return null; - return null; + return ( + + + + + + + + + + + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + +
+ +
+ {m.houses_page_ui_dialog_alert_delete_title({ + name: activeHouse.name, + })} +
+ + {parse(m.houses_page_ui_dialog_alert_delete_description())} + +
+
+ + + + + {m.houses_page_ui_view_table_header_email()} + + + {m.houses_page_ui_view_table_header_role()} + + + + + {activeHouse.members.map((member) => ( + + {member.user.email} + + + + + ))} + +
+
+ + + + + + +
+
+ ); }; export default DeleteUserHouseAction; diff --git a/src/components/house/edit-house-dialog.tsx b/src/components/house/edit-house-dialog.tsx index 2a16f71..75a1244 100644 --- a/src/components/house/edit-house-dialog.tsx +++ b/src/components/house/edit-house-dialog.tsx @@ -3,6 +3,7 @@ import useHasPermission from '@hooks/use-has-permission'; import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; import { m } from '@paraglide/messages'; import { PenIcon } from '@phosphor-icons/react'; +import { Button } from '@ui/button'; import { Dialog, DialogContent, @@ -12,62 +13,63 @@ import { DialogTrigger, } from '@ui/dialog'; import { Label } from '@ui/label'; +import { Skeleton } from '@ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip'; import { useState } from 'react'; type EditHouseProps = { data: HouseWithMembers; - children: React.ReactNode; - isPersonal?: boolean; }; -const EditHouseAction = ({ - data, - children, - isPersonal = false, -}: EditHouseProps) => { +const EditHouseAction = ({ data }: EditHouseProps) => { const [_open, _setOpen] = useState(false); const prevent = usePreventAutoFocus(); const { hasPermission, isLoading } = useHasPermission('house', 'update'); - if (isLoading) return null; - - if (hasPermission) { - return ( - - - - {children} - - - - - - e.preventDefault()} - > - - - - {m.ui_edit_house_btn()} - - - {m.ui_edit_house_btn()} - - - - - - ); + if (isLoading) { + return ; } - return null; + if (!hasPermission) return null; + + return ( + + + + + + + + + + + + e.preventDefault()} + > + + + + {m.ui_edit_house_btn()} + + + {m.ui_edit_house_btn()} + + + + + + ); }; export default EditHouseAction; diff --git a/src/components/house/edit-user-house-dialog.tsx b/src/components/house/edit-user-house-dialog.tsx new file mode 100644 index 0000000..a87b8d4 --- /dev/null +++ b/src/components/house/edit-user-house-dialog.tsx @@ -0,0 +1,83 @@ +import EditHouseForm from '@form/house/admin-edit-house-form'; +import useHasPermission from '@hooks/use-has-permission'; +import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; +import { authClient } from '@lib/auth-client'; +import { m } from '@paraglide/messages'; +import { PenIcon } from '@phosphor-icons/react'; +import { Button } from '@ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@ui/dialog'; +import { Label } from '@ui/label'; +import { Skeleton } from '@ui/skeleton'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip'; +import { useState } from 'react'; + +type EditUserHouseProps = { + data: ReturnType['data']; +}; + +const EditUserHouseAction = ({ data }: EditUserHouseProps) => { + const [_open, _setOpen] = useState(false); + const prevent = usePreventAutoFocus(); + const { hasPermission, isLoading } = useHasPermission( + 'house', + 'update', + true, + ); + + if (isLoading) { + return ; + } + + if (!hasPermission) return null; + + return ( + + + + + + + + + + + + e.preventDefault()} + > + + + + {m.ui_edit_house_btn()} + + + {m.ui_edit_house_btn()} + + + + + + ); +}; + +export default EditUserHouseAction; diff --git a/src/components/house/house-column.tsx b/src/components/house/house-column.tsx index 948beef..598f5e9 100644 --- a/src/components/house/house-column.tsx +++ b/src/components/house/house-column.tsx @@ -1,7 +1,5 @@ import { m } from '@paraglide/messages'; -import { PenIcon } from '@phosphor-icons/react'; import { ColumnDef } from '@tanstack/react-table'; -import { Button } from '@ui/button'; import { formatters } from '@utils/formatters'; import DeleteHouseAction from './delete-house-dialog'; import EditHouseAction from './edit-house-dialog'; @@ -44,17 +42,7 @@ export const houseColumns: ColumnDef[] = [ return (
- - - +
); diff --git a/src/components/house/invite-user-dialog.tsx b/src/components/house/invite-user-dialog.tsx index 321194f..ec7e846 100644 --- a/src/components/house/invite-user-dialog.tsx +++ b/src/components/house/invite-user-dialog.tsx @@ -13,6 +13,7 @@ import { DialogTrigger, } from '@ui/dialog'; import { useState } from 'react'; +import { Skeleton } from '../ui/skeleton'; type ActionProps = {}; @@ -25,41 +26,43 @@ const InviteUserAction = ({}: ActionProps) => { const [_open, _setOpen] = useState(false); const prevent = usePreventAutoFocus(); - if (isLoading) return null; - - if (hasPermission) { - return ( - - - - - e.preventDefault()} - > - - - - {m.houses_user_page_action_invite_user()} - - - {m.houses_user_page_action_invite_user()} - - - - - - ); + if (isLoading) { + return ; } + + if (!hasPermission) return null; + + return ( + + + + + e.preventDefault()} + > + + + + {m.houses_user_page_action_invite_user()} + + + {m.houses_user_page_action_invite_user()} + + + + + + ); }; export default InviteUserAction; diff --git a/src/components/house/leave-house-dialog.tsx b/src/components/house/leave-house-dialog.tsx new file mode 100644 index 0000000..14fb298 --- /dev/null +++ b/src/components/house/leave-house-dialog.tsx @@ -0,0 +1,120 @@ +import useHasPermission from '@/hooks/use-has-permission'; +import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus'; +import { m } from '@/paraglide/messages'; +import { leaveHouse } from '@/service/house.api'; +import { FootprintsIcon } from '@phosphor-icons/react'; +import { useMutation } from '@tanstack/react-query'; +import { Button } from '@ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@ui/dialog'; +import { Label } from '@ui/label'; +import { Skeleton } from '@ui/skeleton'; +import { Spinner } from '@ui/spinner'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +type LeaveHouseProps = { + activeHouseId: string; +}; + +const LeaveHouseAction = ({ activeHouseId }: LeaveHouseProps) => { + const [_open, _setOpen] = useState(false); + const prevent = usePreventAutoFocus(); + const { hasPermission, isLoading } = useHasPermission('house', 'leave', true); + + const { mutate: leaveHouseMutation, isPending } = useMutation({ + mutationFn: leaveHouse, + onSuccess: () => { + toast.success(m.houses_page_message_leave_house_success(), { + richColors: true, + }); + }, + onError: (error: ReturnError) => { + const code = error.code as Parameters< + typeof m.backend_message + >[0]['code']; + toast.error(m.backend_message({ code }), { + richColors: true, + }); + }, + }); + + if (isLoading || !activeHouseId) { + return ; + } + + const onConfirm = async () => { + leaveHouseMutation({ data: { id: activeHouseId } }); + }; + + if (!hasPermission) return null; + + return ( + + + + + + + + + + + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + +
+ +
+ {m.houses_page_ui_dialog_alert_leave_title()} +
+ + {m.houses_page_ui_dialog_alert_leave_description()} + +
+ + + + + + +
+
+ ); +}; + +export default LeaveHouseAction; diff --git a/src/components/house/remove-user-form-house.tsx b/src/components/house/remove-user-form-house.tsx new file mode 100644 index 0000000..8da9762 --- /dev/null +++ b/src/components/house/remove-user-form-house.tsx @@ -0,0 +1,136 @@ +import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus'; +import { authClient } from '@/lib/auth-client'; +import { m } from '@/paraglide/messages'; +import { removeMember } from '@/service/house.api'; +import { housesQueries } from '@/service/queries'; +import { UserCircleMinusIcon } from '@phosphor-icons/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@ui/dialog'; +import { Label } from '@ui/label'; +import { Spinner } from '@ui/spinner'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +type RemoveUserFormProps = { + member: { + id: string; + organizationId: string; + userId: string; + user: { + email: string; + name: string; + }; + }; +}; + +const RemoveUserFormHouse = ({ member }: RemoveUserFormProps) => { + const [_open, _setOpen] = useState(false); + const prevent = usePreventAutoFocus(); + const { refetch } = authClient.useActiveOrganization(); + + const queryClient = useQueryClient(); + + const { mutate: removeMemberMutation, isPending } = useMutation({ + mutationFn: removeMember, + onSuccess: () => { + refetch(); + queryClient.invalidateQueries({ + queryKey: [...housesQueries.all, 'currentUser'], + }); + _setOpen(false); + toast.success(m.houses_page_message_remove_member_success(), { + richColors: true, + }); + }, + onError: (error: ReturnError) => { + const code = error.code as Parameters< + typeof m.backend_message + >[0]['code']; + toast.error(m.backend_message({ code }), { + richColors: true, + }); + }, + }); + + const onConfirm = () => { + removeMemberMutation({ + data: { + houseId: member.organizationId, + memberId: member.id, + }, + }); + }; + + return ( + + + + + + + + + + + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + +
+ +
+ {m.houses_page_ui_dialog_alert_remove_title({ + name: member.user.name, + })} +
+ + {m.houses_page_ui_dialog_alert_remove_description()} + +
+ + + + + + +
+
+ ); +}; + +export default RemoveUserFormHouse; diff --git a/src/components/notification/notification-type/invitation.tsx b/src/components/notification/notification-type/invitation.tsx index 89d423b..e15e80d 100644 --- a/src/components/notification/notification-type/invitation.tsx +++ b/src/components/notification/notification-type/invitation.tsx @@ -1,15 +1,11 @@ import { Button } from '@/components/ui/button'; -import { - ItemActions, - ItemContent, - ItemDescription, - ItemTitle, -} from '@/components/ui/item'; -import { m } from '@/paraglide/messages'; -import { acceptInvitation, rejectInvitation } from '@/service/house.api'; -import { notificationQueries } from '@/service/queries'; import { formatTimeAgo } from '@/utils/helper'; +import { m } from '@paraglide/messages'; +import { acceptInvitation, rejectInvitation } from '@service/house.api'; +import { notificationQueries } from '@service/queries'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ItemActions, ItemContent, ItemDescription, ItemTitle } from '@ui/item'; +import { Spinner } from '@ui/spinner'; import { toast } from 'sonner'; type NotifyProps = { @@ -21,47 +17,49 @@ const NotificationInvitation = ({ notify }: NotifyProps) => { const queryClient = useQueryClient(); - const { mutate: acceptInvitationMutation } = useMutation({ - mutationFn: acceptInvitation, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [...notificationQueries.all, 'list'], - }); - toast.success(m.notification_page_message_invitation_success(), { - richColors: true, - }); - }, - onError: (error: ReturnError) => { - console.error(error); - const code = error.code as Parameters< - typeof m.backend_message - >[0]['code']; - toast.error(m.backend_message({ code }), { - richColors: true, - }); - }, - }); + const { mutate: acceptInvitationMutation, isPending: isAcceptPending } = + useMutation({ + mutationFn: acceptInvitation, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...notificationQueries.all, 'list'], + }); + toast.success(m.notification_page_message_invitation_success(), { + richColors: true, + }); + }, + onError: (error: ReturnError) => { + console.error(error); + const code = error.code as Parameters< + typeof m.backend_message + >[0]['code']; + toast.error(m.backend_message({ code }), { + richColors: true, + }); + }, + }); - const { mutate: rejectInvitationMutation } = useMutation({ - mutationFn: rejectInvitation, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [...notificationQueries.all, 'list'], - }); - toast.success(m.notification_page_message_invitation_rejected(), { - richColors: true, - }); - }, - onError: (error: ReturnError) => { - console.error(error); - const code = error.code as Parameters< - typeof m.backend_message - >[0]['code']; - toast.error(m.backend_message({ code }), { - richColors: true, - }); - }, - }); + const { mutate: rejectInvitationMutation, isPending: isRejectPending } = + useMutation({ + mutationFn: rejectInvitation, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...notificationQueries.all, 'list'], + }); + toast.success(m.notification_page_message_invitation_rejected(), { + richColors: true, + }); + }, + onError: (error: ReturnError) => { + console.error(error); + const code = error.code as Parameters< + typeof m.backend_message + >[0]['code']; + toast.error(m.backend_message({ code }), { + richColors: true, + }); + }, + }); const handleAgreeAction = async () => { if (notify.link) { @@ -95,10 +93,19 @@ const NotificationInvitation = ({ notify }: NotifyProps) => { {notify.link && ( - - diff --git a/src/components/sidebar/nav-main.tsx b/src/components/sidebar/nav-main.tsx index 11cae52..7d7d4b3 100644 --- a/src/components/sidebar/nav-main.tsx +++ b/src/components/sidebar/nav-main.tsx @@ -114,6 +114,9 @@ const NavMain = () => { to={item.path} className="cursor-pointer" tooltip={`${nav.title} - ${item.title}`} + activeProps={{ + className: 'bg-primary text-white', + }} > {item.title} diff --git a/src/components/sidebar/nav-user.tsx b/src/components/sidebar/nav-user.tsx index 30684d9..a7dc409 100644 --- a/src/components/sidebar/nav-user.tsx +++ b/src/components/sidebar/nav-user.tsx @@ -35,7 +35,7 @@ const NavUser = () => { const navigate = useNavigate(); const { isMobile } = useSidebar(); const queryClient = useQueryClient(); - const { data: session } = useAuth(); + const { session } = useAuth(); const signout = async () => { await authClient.signOut({ diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4ac6dbb..b0fd244 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { cn } from '@lib/utils'; const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white disabled:data-[dot=true]:opacity-100 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white disabled:data-[dot=true]:opacity-100 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer", { variants: { variant: { diff --git a/src/hooks/use-has-permission.ts b/src/hooks/use-has-permission.ts index ff5f384..60cced2 100644 --- a/src/hooks/use-has-permission.ts +++ b/src/hooks/use-has-permission.ts @@ -6,11 +6,19 @@ function useHasPermission( action: string, houseCheck: boolean = false, ) { + const { data: activeOrganization } = authClient.useActiveOrganization(); const [hasPermission, setHasPermission] = useState(false); const [isLoading, setIsLoading] = useState(true); useEffect(() => { + if (houseCheck && !activeOrganization?.id) { + setIsLoading(false); + setHasPermission(false); + return; + } + const checkPermission = async () => { + setIsLoading(true); try { const option = { permissions: { @@ -29,7 +37,7 @@ function useHasPermission( } }; checkPermission(); - }, [resource, action]); + }, [resource, action, houseCheck, activeOrganization?.id]); return { hasPermission, isLoading }; } diff --git a/src/lib/auth/organization-permissions.ts b/src/lib/auth/organization-permissions.ts index 27eb28d..25a5f01 100644 --- a/src/lib/auth/organization-permissions.ts +++ b/src/lib/auth/organization-permissions.ts @@ -1,37 +1,37 @@ -import { createAccessControl } from 'better-auth/plugins/access' +import { createAccessControl } from 'better-auth/plugins/access'; import { - defaultStatements, adminAc, + defaultStatements, ownerAc, -} from 'better-auth/plugins/organization/access' +} from 'better-auth/plugins/organization/access'; const statement = { ...defaultStatements, - house: ['list', 'create', 'update', 'delete'], + house: ['list', 'create', 'update', 'delete', 'leave'], box: ['list', 'create', 'update', 'delete'], item: ['list', 'create', 'update', 'delete'], -} as const +} as const; -const acOrg = createAccessControl(statement) +const acOrg = createAccessControl(statement); const owner = acOrg.newRole({ ...ownerAc.statements, house: ['list', 'create', 'update', 'delete'], box: ['list', 'create', 'update', 'delete'], item: ['list', 'create', 'update', 'delete'], -}) +}); const adminOrg = acOrg.newRole({ ...adminAc.statements, - house: ['list', 'create', 'update', 'delete'], + house: ['list', 'create', 'update', 'leave'], box: ['list', 'create', 'update', 'delete'], item: ['list', 'create', 'update', 'delete'], -}) +}); const member = acOrg.newRole({ - house: ['list', 'create', 'update', 'delete'], - box: ['list', 'create', 'update', 'delete'], + house: ['list', 'leave'], + box: ['list'], item: ['list', 'create', 'update', 'delete'], -}) +}); -export { acOrg, owner, adminOrg, member } +export { acOrg, adminOrg, member, owner }; diff --git a/src/service/house.api.ts b/src/service/house.api.ts index bd05561..3b2fe08 100644 --- a/src/service/house.api.ts +++ b/src/service/house.api.ts @@ -13,6 +13,7 @@ import { houseEditBESchema, houseListSchema, invitationCreateBESchema, + removeMemberSchema, } from './house.schema'; import { createAuditLog, createNotification } from './repository'; @@ -347,7 +348,7 @@ export const cancelInvitation = createServerFn({ method: 'POST' }) export const acceptInvitation = createServerFn({ method: 'POST' }) .middleware([authMiddleware]) .inputValidator(actionInvitationSchema) - .handler(async ({ data }) => { + .handler(async ({ data, context: { user } }) => { try { const result = await prisma.invitation.update({ where: { id: data.id }, @@ -360,13 +361,33 @@ export const acceptInvitation = createServerFn({ method: 'POST' }) data: { link: null }, }); - await auth.api.addMember({ + const member = await auth.api.addMember({ body: { userId: notify.userId, organizationId: result.organizationId, role: (result.role as 'admin' | 'owner' | 'member') || 'member', }, }); + + await createAuditLog({ + action: LOG_ACTION.UPDATE, + tableName: DB_TABLE.INVITATION, + recordId: result.id, + oldValue: JSON.stringify(result), + newValue: 'Accept Invitation / Đồng ý mời', + userId: user.id, + }); + + if (member) { + await createAuditLog({ + action: LOG_ACTION.CREATE, + tableName: DB_TABLE.MEMBER, + recordId: member.id, + oldValue: '', + newValue: JSON.stringify(member), + userId: user.id, + }); + } } return result; @@ -404,3 +425,72 @@ export const rejectInvitation = createServerFn({ method: 'POST' }) throw { message, code }; } }); + +export const leaveHouse = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) + .inputValidator(baseHouse) + .handler(async ({ data }) => { + try { + const headers = getRequestHeaders(); + const result = await auth.api.leaveOrganization({ + body: { + organizationId: data.id, + }, + headers, + }); + + if (result) { + await createAuditLog({ + action: LOG_ACTION.UPDATE, + tableName: DB_TABLE.ORGANIZATION, + recordId: result.id, + oldValue: JSON.stringify(result), + newValue: `${result.user.name} đã rời khỏi nhà (left the house)`, + userId: result.user.id, + }); + } + + return result; + } catch (error) { + console.error(error); + const { message, code } = parseError(error); + throw { message, code }; + } + }); + +export const removeMember = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) + .inputValidator(removeMemberSchema) + .handler(async ({ data, context: { user } }) => { + try { + const headers = getRequestHeaders(); + const result = await auth.api.removeMember({ + body: { + memberIdOrEmail: data.memberId, + organizationId: data.houseId, + }, + headers, + }); + + if (result) { + const oldMember = await prisma.user.findUnique({ + where: { id: result.member.userId }, + }); + + await createAuditLog({ + action: LOG_ACTION.UPDATE, + tableName: DB_TABLE.MEMBER, + recordId: result.member.id, + oldValue: JSON.stringify(result), + newValue: `${oldMember?.name} đã bị "mời" rời khỏi nhà (was "asked" to leave the house.)`, + userId: user.id, + }); + } + + return result; + } catch (error) { + console.error(error); + const { message, code } = parseError(error); + throw { message, code }; + } + }); diff --git a/src/service/house.schema.ts b/src/service/house.schema.ts index e8feb3b..d2a8ed7 100644 --- a/src/service/house.schema.ts +++ b/src/service/house.schema.ts @@ -71,3 +71,8 @@ export const invitationCreateBESchema = invitationCreateSchema.extend({ export const actionInvitationSchema = baseInvitation.extend({ notificationId: z.string().nonempty(m.notification_page_notify_not_found()), }); + +export const removeMemberSchema = z.object({ + memberId: z.string().nonempty(m.users_page_message_user_not_found()), + houseId: z.string().nonempty(m.houses_page_message_house_not_found()), +});