diff --git a/messages/en.json b/messages/en.json index 38e9fbf..28d3b6a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -162,6 +162,8 @@ "houses_page_form_user": "User", "houses_page_form_create_for": "Create for", "houses_page_form_color": "Color", + "houses_page_form_user_select_placeholder": "Select user", + "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_message_create_house_success": "Created house successfully!", @@ -171,6 +173,20 @@ "houses_page_house_active_btn": "Active", "houses_user_page_message_active_house_success": "Active \"{house}\" successfully!", "houses_user_page_block_action_title": "Action", + "houses_user_page_action_invite_user": "Invite member", + "houses_user_page_invite_label_to": "To", + "houses_user_page_invite_label_status": "Status", + "invite_status": [ + { + "match": { + "status=pending": "Pending", + "status=accept": "Accept", + "status=reject": "Reject", + "status=expired": "Expired", + "status=canceled": "Cancel" + } + } + ], "backend_message": [ { "match": { @@ -180,7 +196,8 @@ "code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!", "code=BANNED_USER": "Your account get banned, please contact administrator for more information!", "code=VALIDATION_ERROR": "Some field value invalid!", - "code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the house" + "code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the house", + "code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "This member has already been invited, waiting for the member to join!" } } ] diff --git a/messages/vi.json b/messages/vi.json index f1b26e0..c579f5c 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -159,10 +159,13 @@ "houses_page_ui_view_label_count": "Số lượng", "houses_page_ui_view_table_header_email": "Email", "houses_page_ui_view_table_header_role": "Quyền hạn", + "houses_page_ui_view_table_header_invite": "Lời mời đã gởi", "houses_page_form_name": "Tên nhà", "houses_page_form_user": "Người dùng", "houses_page_form_create_for": "Tạo cho", "houses_page_form_color": "Màu sắc", + "houses_page_form_user_select_placeholder": "Chọn người dùng", + "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_message_create_house_success": "Tạo nhà thành công!", @@ -172,6 +175,20 @@ "houses_page_house_active_btn": "Kích hoạt", "houses_user_page_message_active_house_success": "Kích hoạt \"{house}\" thành công!", "houses_user_page_block_action_title": "Hành động", + "houses_user_page_action_invite_user": "Mời thành viên", + "houses_user_page_invite_label_to": "Đến", + "houses_user_page_invite_label_status": "Trạng thái", + "invite_status": [ + { + "match": { + "status=pending": "Đang chờ", + "status=accept": "Đồng ý", + "status=reject": "Không đồng ý", + "status=expired": "Hết hạn", + "status=canceled": "Đã hủy" + } + } + ], "backend_message": [ { "match": { @@ -181,7 +198,8 @@ "code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email này đã có người sử dụng. Vui lòng chọn một email khác!", "code=BANNED_USER": "Bạn đã bị quản trị viên khóa tài khoản, hãy liên hệ quản trị viên để tìm hiểu thêm!", "code=VALIDATION_ERROR": "Có giá trị không hợp lệ!", - "code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "Người dùng này không phải thành viên nhà này" + "code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "Người dùng này không phải thành viên nhà này", + "code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "Thành viên này đã được mời rồi, còn đang đợi thành viên đồng ý!" } } ] diff --git a/src/components/avatar/role-badge.tsx b/src/components/avatar/role-badge.tsx index 6e7666e..b4ddc29 100644 --- a/src/components/avatar/role-badge.tsx +++ b/src/components/avatar/role-badge.tsx @@ -1,3 +1,4 @@ +import { ROLE_NAME } from '@/types/enum'; import { m } from '@paraglide/messages'; import { Badge, badgeVariants } from '@ui/badge'; import { VariantProps } from 'class-variance-authority'; @@ -27,7 +28,7 @@ const RoleBadge = ({ type, className }: RoleProps) => { return ( - {m.role_tags({ role: type as string })} + {m.role_tags({ role: type as ROLE_NAME })} ); }; diff --git a/src/components/form/form-components.tsx b/src/components/form/form-components.tsx index 92e66cf..6280f36 100644 --- a/src/components/form/form-components.tsx +++ b/src/components/form/form-components.tsx @@ -1,5 +1,4 @@ import { useFieldContext, useFormContext } from '@hooks/use-app-form'; -import { RoleEnum } from '@service/user.schema'; import { useStore } from '@tanstack/react-form'; import { Button, buttonVariants } from '@ui/button'; import { Field, FieldError, FieldLabel } from '@ui/field'; @@ -146,12 +145,11 @@ export function Select({ label, values, placeholder, - isRole = false, + // isRole = false, }: { label: string; values: Array<{ label: string; value: string }>; placeholder?: string; - isRole?: boolean; }) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); @@ -163,11 +161,7 @@ export function Select({ - isRole - ? field.handleChange(RoleEnum.parse(value)) - : field.handleChange(value) - } + onValueChange={(value) => field.handleChange(value)} > @@ -235,6 +229,7 @@ export function SelectUser({ keyword, onKeywordChange, searchPlaceholder = 'Tìm theo tên hoặc email...', + selectKey = 'id', }: { label: string; values: Array<{ id: string; name: string; email: string }>; @@ -243,6 +238,7 @@ export function SelectUser({ keyword?: string; onKeywordChange?: (value: string) => void; searchPlaceholder?: string; + selectKey?: 'id' | 'email'; }) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); @@ -262,6 +258,7 @@ export function SelectUser({ onKeywordChange={onKeywordChange} searchPlaceholder={searchPlaceholder} aria-invalid={isInvalid} + selectKey={selectKey} /> {isInvalid && } diff --git a/src/components/form/house/admin-create-house-form.tsx b/src/components/form/house/admin-create-house-form.tsx index 462ed16..9951e54 100644 --- a/src/components/form/house/admin-create-house-form.tsx +++ b/src/components/form/house/admin-create-house-form.tsx @@ -84,7 +84,6 @@ const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => { if (isPersonal) { form.setFieldValue('userId', session.user.id); } - console.log(isPending); }, []); return ( @@ -111,7 +110,8 @@ const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => { diff --git a/src/components/form/house/user-invite-member-form.tsx b/src/components/form/house/user-invite-member-form.tsx new file mode 100644 index 0000000..7e27c0d --- /dev/null +++ b/src/components/form/house/user-invite-member-form.tsx @@ -0,0 +1,132 @@ +import { Button } from '@/components/ui/button'; +import { DialogClose, DialogFooter } from '@/components/ui/dialog'; +import { useAppForm } from '@/hooks/use-app-form'; +import useDebounced from '@/hooks/use-debounced'; +import { authClient } from '@/lib/auth-client'; +import { m } from '@/paraglide/messages'; +import { invitationMember } from '@/service/house.api'; +import { + invitationCreateBESchema, + invitationCreateFESchema, +} from '@/service/house.schema'; +import { housesQueries, usersQueries } from '@/service/queries'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Field, FieldGroup, FieldLabel } from '@ui/field'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +type FormProps = { + onSubmit: (open: boolean) => void; +}; + +const UserInviteMemberForm = ({ onSubmit }: FormProps) => { + const { data: activeHouse, refetch } = authClient.useActiveOrganization(); + const [userKeyword, setUserKeyword] = useState(''); + const debouncedUserKeyword = useDebounced(userKeyword, 300); + const { data: users } = useQuery( + usersQueries.select({ keyword: debouncedUserKeyword }, true), + ); + const queryClient = useQueryClient(); + + if (!activeHouse) return null; + + const { mutate: invitationMemberMutation, isPending } = useMutation({ + mutationFn: invitationMember, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...housesQueries.all, 'currentUser'], + }); + onSubmit(false); + refetch(); + toast.success(m.houses_page_message_create_house_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 form = useAppForm({ + defaultValues: { + email: '', + houseId: activeHouse.id, + role: '', + }, + validators: { + onChange: invitationCreateFESchema, + onSubmit: invitationCreateFESchema, + }, + onSubmit: async ({ value }) => { + invitationMemberMutation({ data: invitationCreateBESchema.parse(value) }); + }, + }); + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + + + {m.houses_page_form_name()}: + +
{activeHouse.name}
+
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + + + + + + + +
+
+ ); +}; + +export default UserInviteMemberForm; diff --git a/src/components/form/user/admin-create-user-form.tsx b/src/components/form/user/admin-create-user-form.tsx index f2f8b80..d3f8180 100644 --- a/src/components/form/user/admin-create-user-form.tsx +++ b/src/components/form/user/admin-create-user-form.tsx @@ -2,7 +2,7 @@ import { useAppForm } from '@hooks/use-app-form'; import { m } from '@paraglide/messages'; import { usersQueries } from '@service/queries'; import { createUser } from '@service/user.api'; -import { userCreateSchema } from '@service/user.schema'; +import { userCreateBESchema, userCreateFESchema } from '@service/user.schema'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@ui/button'; import { DialogClose, DialogFooter } from '@ui/dialog'; @@ -46,11 +46,11 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => { role: '', }, validators: { - onChange: userCreateSchema, - onSubmit: userCreateSchema, + onChange: userCreateFESchema, + onSubmit: userCreateFESchema, }, onSubmit: ({ value }) => { - createUserMutation({ data: userCreateSchema.parse(value) }); + createUserMutation({ data: userCreateBESchema.parse(value) }); }, }); @@ -83,7 +83,6 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => { { onSubmit: userUpdateRoleSchema, }, onSubmit: async ({ value }) => { - updateRoleMutation({ data: value }); + updateRoleMutation({ data: userUpdateRoleBESchema.parse(value) }); }, }); diff --git a/src/components/house/create-house-dialog.tsx b/src/components/house/create-house-dialog.tsx index 662037d..096fb4b 100644 --- a/src/components/house/create-house-dialog.tsx +++ b/src/components/house/create-house-dialog.tsx @@ -1,7 +1,7 @@ -import { cn } from '@/lib/utils'; import CreateNewHouseForm from '@form/house/admin-create-house-form'; import useHasPermission from '@hooks/use-has-permission'; import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; +import { cn } from '@lib/utils'; import { m } from '@paraglide/messages'; import { PlusIcon } from '@phosphor-icons/react'; import { Button } from '@ui/button'; @@ -24,7 +24,11 @@ const CreateNewHouse = ({ className, isPersonal = false, }: CreateNewHouseProp) => { - const { hasPermission, isLoading } = useHasPermission('house', 'create'); + const { hasPermission, isLoading } = useHasPermission( + 'house', + 'create', + isPersonal, + ); const [_open, _setOpen] = useState(false); const prevent = usePreventAutoFocus(); diff --git a/src/components/house/current-user-action-group.tsx b/src/components/house/current-user-action-group.tsx index 5fa0644..440b1a5 100644 --- a/src/components/house/current-user-action-group.tsx +++ b/src/components/house/current-user-action-group.tsx @@ -1,8 +1,8 @@ import { authClient } from '@lib/auth-client'; import { m } from '@paraglide/messages'; import { GearIcon, PenIcon } from '@phosphor-icons/react'; +import { Button } from '@ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; -import { Button } from '../ui/button'; import DeleteUserHouseAction from './delete-user-house-dialog'; import EditHouseAction from './edit-house-dialog'; diff --git a/src/components/house/current-user-house-list.tsx b/src/components/house/current-user-house-list.tsx index bb95792..88fa624 100644 --- a/src/components/house/current-user-house-list.tsx +++ b/src/components/house/current-user-house-list.tsx @@ -4,17 +4,17 @@ import { m } from '@paraglide/messages'; import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react'; import { Button } from '@ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; -import parse from 'html-react-parser'; -import { toast } from 'sonner'; import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle, -} from '../ui/item'; -import { ScrollArea, ScrollBar } from '../ui/scroll-area'; -import { Skeleton } from '../ui/skeleton'; +} from '@ui/item'; +import { ScrollArea, ScrollBar } from '@ui/scroll-area'; +import { Skeleton } from '@ui/skeleton'; +import parse from 'html-react-parser'; +import { toast } from 'sonner'; import CreateNewHouse from './create-house-dialog'; type CurrentUserHouseListProps = { @@ -26,8 +26,6 @@ const CurrentUserHouseList = ({ activeHouse, houses, }: CurrentUserHouseListProps) => { - // const { data: houses } = useQuery(housesQueries.currentUser()); - const activeHouseAction = async ({ id, slug, diff --git a/src/components/house/current-user-invitation-list.tsx b/src/components/house/current-user-invitation-list.tsx new file mode 100644 index 0000000..b6f58e2 --- /dev/null +++ b/src/components/house/current-user-invitation-list.tsx @@ -0,0 +1,116 @@ +import { cancelInvitation } from '@/service/house.api'; +import { INVITE_STATUS } from '@/types/enum'; +import { authClient } from '@lib/auth-client'; +import { m } from '@paraglide/messages'; +import { useMutation } from '@tanstack/react-query'; +import { Item, ItemContent, ItemDescription, ItemTitle } from '@ui/item'; +import { Skeleton } from '@ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@ui/table'; +import { toast } from 'sonner'; +import RoleBadge from '../avatar/role-badge'; +import { Button } from '../ui/button'; + +type InvitationListProps = { + activeHouse: ReturnType['data']; +}; + +const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => { + const { refetch } = authClient.useActiveOrganization(); + + const { mutate: cancelInvitationMutation } = useMutation({ + mutationFn: cancelInvitation, + onSuccess: () => { + refetch(); + // _setOpen(false); + toast.success(m.houses_page_message_delete_house_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 handleCancelInvitation = (id: string) => { + cancelInvitationMutation({ data: { id } }); + }; + + if (!activeHouse) { + return ; + } + + return ( +
+ + + + + {m.houses_page_ui_view_table_header_invite()} + + + + + + {activeHouse.invitations.length > 0 ? ( + activeHouse.invitations.map((item) => ( + + + + + + {m.houses_user_page_invite_label_to()}:{' '} + {item.email} - + + + + {m.houses_user_page_invite_label_status()}:{' '} + {m.invite_status({ + status: item.status as INVITE_STATUS, + })} + + + + + + +
+ {item.status !== INVITE_STATUS.CANCELED && ( + + )} +
+
+
+ )) + ) : ( + + + No results. + + + )} +
+
+
+ ); +}; + +export default CurrentUserInvitationList; diff --git a/src/components/house/current-user-member-list.tsx b/src/components/house/current-user-member-list.tsx index 16c069f..e0e74f5 100644 --- a/src/components/house/current-user-member-list.tsx +++ b/src/components/house/current-user-member-list.tsx @@ -1,5 +1,6 @@ import { authClient } from '@lib/auth-client'; import { m } from '@paraglide/messages'; +import { Item, ItemContent, ItemDescription, ItemTitle } from '@ui/item'; import { Skeleton } from '@ui/skeleton'; import { Table, @@ -10,7 +11,7 @@ import { TableRow, } from '@ui/table'; import RoleBadge from '../avatar/role-badge'; -import { Item, ItemContent, ItemDescription, ItemTitle } from '../ui/item'; +import InviteUserAction from './invite-user-dialog'; type CurrentUserMemberListProps = { activeHouse: ReturnType['data']; @@ -23,7 +24,7 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => { return (
- +
@@ -33,7 +34,11 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => { {m.houses_page_ui_view_table_header_role()} - + +
+ +
+
diff --git a/src/components/house/delete-house-dialog.tsx b/src/components/house/delete-house-dialog.tsx index c6bcdeb..c8f6905 100644 --- a/src/components/house/delete-house-dialog.tsx +++ b/src/components/house/delete-house-dialog.tsx @@ -1,9 +1,9 @@ -import { m } from '@/paraglide/messages'; -import { deleteHouse } from '@/service/house.api'; -import { housesQueries } from '@/service/queries'; import useHasPermission from '@hooks/use-has-permission'; import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; +import { m } from '@paraglide/messages'; import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react'; +import { deleteHouse } from '@service/house.api'; +import { housesQueries } from '@service/queries'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@ui/button'; import { @@ -17,6 +17,7 @@ import { DialogTrigger, } from '@ui/dialog'; import { Label } from '@ui/label'; +import { Spinner } from '@ui/spinner'; import { Table, TableBody, @@ -30,7 +31,6 @@ import parse from 'html-react-parser'; import { useState } from 'react'; import { toast } from 'sonner'; import RoleBadge from '../avatar/role-badge'; -import { Spinner } from '../ui/spinner'; type DeleteHouseProps = { data: HouseWithMembers; diff --git a/src/components/house/delete-user-house-dialog.tsx b/src/components/house/delete-user-house-dialog.tsx index d68bb79..5d4be91 100644 --- a/src/components/house/delete-user-house-dialog.tsx +++ b/src/components/house/delete-user-house-dialog.tsx @@ -1,10 +1,10 @@ -import { authClient } from '@/lib/auth-client'; -import { m } from '@/paraglide/messages'; -import { deleteUserHouse } from '@/service/house.api'; -import { housesQueries } from '@/service/queries'; 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 { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react'; +import { deleteUserHouse } from '@service/house.api'; +import { housesQueries } from '@service/queries'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@ui/button'; import { @@ -18,6 +18,7 @@ import { DialogTrigger, } from '@ui/dialog'; import { Label } from '@ui/label'; +import { Spinner } from '@ui/spinner'; import { Table, TableBody, @@ -31,7 +32,6 @@ import parse from 'html-react-parser'; import { useState } from 'react'; import { toast } from 'sonner'; import RoleBadge from '../avatar/role-badge'; -import { Spinner } from '../ui/spinner'; type DeleteUserHouseProps = { activeHouse: ReturnType['data']; diff --git a/src/components/house/house-column.tsx b/src/components/house/house-column.tsx index 4992614..948beef 100644 --- a/src/components/house/house-column.tsx +++ b/src/components/house/house-column.tsx @@ -1,8 +1,8 @@ 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 { Button } from '../ui/button'; import DeleteHouseAction from './delete-house-dialog'; import EditHouseAction from './edit-house-dialog'; import ViewDetailHouse from './view-house-detail-dialog'; diff --git a/src/components/house/invite-user-dialog.tsx b/src/components/house/invite-user-dialog.tsx new file mode 100644 index 0000000..fd3c171 --- /dev/null +++ b/src/components/house/invite-user-dialog.tsx @@ -0,0 +1,65 @@ +import UserInviteMemberForm from '@form/house/user-invite-member-form'; +import useHasPermission from '@hooks/use-has-permission'; +import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; +import { m } from '@paraglide/messages'; +import { PaperPlaneTiltIcon } from '@phosphor-icons/react'; +import { Button } from '@ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@ui/dialog'; +import { useState } from 'react'; + +type ActionProps = {}; + +const InviteUserAction = ({}: ActionProps) => { + const { hasPermission, isLoading } = useHasPermission( + 'house', + 'create', + true, + ); + 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()} + + + + + + ); + } +}; + +export default InviteUserAction; diff --git a/src/components/ui/select-user.tsx b/src/components/ui/select-user.tsx index f9e454f..bea7ef9 100644 --- a/src/components/ui/select-user.tsx +++ b/src/components/ui/select-user.tsx @@ -4,7 +4,7 @@ import { cn } from '@lib/utils'; import { CaretDownIcon, MagnifyingGlassIcon } from '@phosphor-icons/react'; import { useCallback, useEffect, useRef, useState } from 'react'; -export type SelectUserItem = { +type SelectUserItem = { id: string; name: string; email: string; @@ -13,7 +13,7 @@ export type SelectUserItem = { const userLabel = (u: { name: string; email: string }) => `${u.name} - ${u.email}`; -export type SelectUserProps = { +type SelectUserProps = { value: string; onValueChange: (userId: string) => void; values: SelectUserItem[]; @@ -27,6 +27,7 @@ export type SelectUserProps = { 'aria-invalid'?: boolean; disabled?: boolean; className?: string; + selectKey?: 'id' | 'email'; }; export function SelectUser({ @@ -42,6 +43,7 @@ export function SelectUser({ 'aria-invalid': ariaInvalid, disabled = false, className, + selectKey = 'id', }: SelectUserProps) { const [open, setOpen] = useState(false); const [localQuery, setLocalQuery] = useState(''); @@ -53,7 +55,9 @@ export function SelectUser({ const setSearchValue = useServerSearch ? onKeywordChange! : setLocalQuery; const selectedUser = - value != null && value !== '' ? values.find((u) => u.id === value) : null; + value != null && value !== '' + ? values.find((u) => u[selectKey] === value) + : null; const displayValue = selectedUser ? userLabel(selectedUser) : ''; const filtered = useServerSearch @@ -161,15 +165,16 @@ export function SelectUser({ key={u.id} type="button" role="option" - aria-selected={value === u.id} + aria-selected={value === u[selectKey]} className={cn( 'hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-xs/relaxed outline-none', - value === u.id && 'bg-accent text-accent-foreground', + value === u[selectKey] && + 'bg-accent text-accent-foreground', )} onClick={(e) => { e.preventDefault(); e.stopPropagation(); - handleSelect(u.id); + handleSelect(u[selectKey]); }} > {userLabel(u)} diff --git a/src/hooks/use-has-permission.ts b/src/hooks/use-has-permission.ts index ed515d8..ff5f384 100644 --- a/src/hooks/use-has-permission.ts +++ b/src/hooks/use-has-permission.ts @@ -1,18 +1,26 @@ import { authClient } from '@lib/auth-client'; import { useEffect, useState } from 'react'; -function useHasPermission(resource: string, action: string) { +function useHasPermission( + resource: string, + action: string, + houseCheck: boolean = false, +) { const [hasPermission, setHasPermission] = useState(false); const [isLoading, setIsLoading] = useState(true); useEffect(() => { const checkPermission = async () => { try { - const access = await authClient.admin.hasPermission({ + const option = { permissions: { [resource]: [action], }, - }); + }; + const access = houseCheck + ? await authClient.organization.hasPermission(option) + : await authClient.admin.hasPermission(option); + setHasPermission(access.data?.success ?? false); } catch (error) { console.error('Permission check failed:', error); diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts index 801d14c..52b8b21 100644 --- a/src/lib/middleware.ts +++ b/src/lib/middleware.ts @@ -19,6 +19,7 @@ export const authMiddleware = createMiddleware({ type: 'function' }).server( name: session?.user?.name, email: session?.user?.email, image: session?.user?.image, + activeHouseId: session?.session?.activeOrganizationId, }, }, }); diff --git a/src/routes/(app)/(auth)/management/houses.tsx b/src/routes/(app)/(auth)/management/houses.tsx index a71d8c7..16c2521 100644 --- a/src/routes/(app)/(auth)/management/houses.tsx +++ b/src/routes/(app)/(auth)/management/houses.tsx @@ -1,5 +1,6 @@ import CurrentUserActionGroup from '@/components/house/current-user-action-group'; import CurrentUserHouseList from '@/components/house/current-user-house-list'; +import CurrentUserInvitationList from '@/components/house/current-user-invitation-list'; import CurrentUserMemberList from '@/components/house/current-user-member-list'; import { m } from '@/paraglide/messages'; import { housesQueries } from '@/service/queries'; @@ -27,6 +28,7 @@ function RouteComponent() { activeHouse={activeHouse} oneHouse={houses.length === 1} /> + ); diff --git a/src/service/house.api.ts b/src/service/house.api.ts index b5e0657..a58ac4e 100644 --- a/src/service/house.api.ts +++ b/src/service/house.api.ts @@ -11,6 +11,7 @@ import { houseCreateBESchema, houseEditBESchema, houseListSchema, + invitationCreateBESchema, } from './house.schema'; import { createAuditLog } from './repository'; @@ -252,3 +253,59 @@ export const deleteUserHouse = createServerFn({ method: 'POST' }) throw { message, code }; } }); + +export const invitationMember = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) + .inputValidator(invitationCreateBESchema) + .handler(async ({ data, context: { user } }) => { + try { + const headers = getRequestHeaders(); + const body = { + email: data.email, + role: data.role, + organizationId: data.houseId, + }; + + const result = await auth.api.createInvitation({ + body, + headers, + }); + + if (result) { + await createAuditLog({ + action: LOG_ACTION.CREATE, + tableName: DB_TABLE.INVITATION, + recordId: result.id, + oldValue: '', + newValue: JSON.stringify(body), + userId: user.id, + }); + } + + return result; + } catch (error) { + console.error(error); + const { message, code } = parseError(error); + throw { message, code }; + } + }); + +export const cancelInvitation = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) + .inputValidator(baseHouse) + .handler(async ({ data }) => { + try { + const headers = getRequestHeaders(); + const result = await auth.api.cancelInvitation({ + body: { + invitationId: data.id, // required + }, + headers, + }); + 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 f5379ae..f98fd96 100644 --- a/src/service/house.schema.ts +++ b/src/service/house.schema.ts @@ -43,3 +43,23 @@ export const houseEditSchema = baseHouse.extend({ export const houseEditBESchema = houseEditSchema.extend({ slug: z.string().nonempty(m.common_is_required({ field: 'Slug' })), }); + +export const RoleHouseEnum = z.enum( + ['owner', 'admin', 'member'], + m.users_page_message_role_select(), +); + +const invitationCreateSchema = z.object({ + email: z + .string() + .nonempty(m.common_is_required({ field: m.houses_page_form_user() })), + houseId: z.string().nonempty(m.houses_page_message_house_not_found()), +}); + +export const invitationCreateFESchema = invitationCreateSchema.extend({ + role: z.string().nonempty(m.users_page_message_role_select()), +}); + +export const invitationCreateBESchema = invitationCreateSchema.extend({ + role: RoleHouseEnum, +}); diff --git a/src/service/queries.ts b/src/service/queries.ts index 718ca55..25ec7ea 100644 --- a/src/service/queries.ts +++ b/src/service/queries.ts @@ -55,10 +55,10 @@ export const usersQueries = { queryKey: [...usersQueries.all, 'list', params], queryFn: () => getAllUser({ data: params }), }), - select: (params: { keyword?: string }) => + select: (params: { keyword?: string }, noself: boolean = false) => queryOptions({ queryKey: [...usersQueries.all, 'select', params], - queryFn: () => getUserForSelect({ data: params }), + queryFn: () => getUserForSelect({ data: { ...params, noself } }), }), }; diff --git a/src/service/user.api.ts b/src/service/user.api.ts index aabfb11..acae38d 100644 --- a/src/service/user.api.ts +++ b/src/service/user.api.ts @@ -9,12 +9,12 @@ import { createAuditLog } from './repository'; import { baseUser, userBanSchema, - userCreateSchema, + userCreateBESchema, userForSelectSchema, userListSchema, userSetPasswordSchema, userUpdateInfoSchema, - userUpdateRoleSchema, + userUpdateRoleBESchema, } from './user.schema'; export const getAllUser = createServerFn({ method: 'GET' }) @@ -59,9 +59,9 @@ export const getAllUser = createServerFn({ method: 'GET' }) export const getUserForSelect = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .inputValidator(userForSelectSchema) - .handler(async ({ data }) => { + .handler(async ({ data, context: { user } }) => { try { - const { keyword } = data; + const { keyword, noself } = data; const result = await prisma.user.findMany({ where: { @@ -79,6 +79,11 @@ export const getUserForSelect = createServerFn({ method: 'GET' }) }, }, ], + AND: { + NOT: { + id: noself ? user.id : undefined, + }, + }, }, select: { id: true, @@ -169,7 +174,7 @@ export const updateUserInformation = createServerFn({ method: 'POST' }) export const setUserRole = createServerFn({ method: 'POST' }) .middleware([authMiddleware]) - .inputValidator(userUpdateRoleSchema) + .inputValidator(userUpdateRoleBESchema) .handler(async ({ data, context: { user } }) => { try { const currentUser = await prisma.user.findUnique({ @@ -275,7 +280,7 @@ export const unbanUser = createServerFn({ method: 'POST' }) export const createUser = createServerFn({ method: 'POST' }) .middleware([authMiddleware]) - .inputValidator(userCreateSchema) + .inputValidator(userCreateBESchema) .handler(async ({ data, context: { user } }) => { try { const result = await auth.api.createUser({ diff --git a/src/service/user.schema.ts b/src/service/user.schema.ts index 71d7f49..de3fc24 100644 --- a/src/service/user.schema.ts +++ b/src/service/user.schema.ts @@ -56,6 +56,7 @@ export const userListSchema = z.object({ export const userForSelectSchema = z.object({ keyword: z.string().optional(), + noself: z.boolean().optional().default(false), }); export const userSetPasswordSchema = baseUser.extend({ @@ -82,6 +83,10 @@ export const RoleEnum = z.enum( ); export const userUpdateRoleSchema = baseUser.extend({ + role: z.string().nonempty(m.users_page_message_role_select()), +}); + +export const userUpdateRoleBESchema = baseUser.extend({ role: RoleEnum, }); @@ -94,7 +99,7 @@ export const userBanSchema = baseUser.extend({ banExp: z.number().int().min(1, m.users_page_message_select_min_one_day()), }); -export const userCreateSchema = z.object({ +const userCreateBaseSchema = z.object({ email: z .string() .nonempty(m.common_is_required({ field: m.login_page_form_email() })) @@ -111,5 +116,12 @@ export const userCreateSchema = z.object({ field: m.profile_form_name(), }), ), +}); + +export const userCreateFESchema = userCreateBaseSchema.extend({ + role: z.string().nonempty(m.users_page_message_role_select()), +}); + +export const userCreateBESchema = userCreateBaseSchema.extend({ role: RoleEnum, }); diff --git a/src/types/enum.ts b/src/types/enum.ts index 59fd043..690e506 100644 --- a/src/types/enum.ts +++ b/src/types/enum.ts @@ -26,3 +26,22 @@ export const DB_TABLE = { } as const; export type DB_TABLE = (typeof DB_TABLE)[keyof typeof DB_TABLE]; + +export const ROLE_NAME = { + ADMIN: 'admin', + USER: 'user', + MEMBER: 'member', + OWNER: 'owner', +} as const; + +export type ROLE_NAME = (typeof ROLE_NAME)[keyof typeof ROLE_NAME]; + +export const INVITE_STATUS = { + PENDING: 'pending', + ACCEPT: 'accept', + REJECT: 'reject', + CANCELED: 'canceled', + EXPIRED: 'expired', +} as const; + +export type INVITE_STATUS = (typeof INVITE_STATUS)[keyof typeof INVITE_STATUS];