invite member to house
This commit is contained in:
@@ -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: <b>Box</b>, <b>Item</b>. 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 \"<b>{house}</b>\" 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!"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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ư: <b>Hộp chứa</b>, <b>Vật Phẩm</b>. 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 \"<b>{house}</b>\" 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 ý!"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 (
|
||||
<Badge variant={displayVariant} className={className}>
|
||||
{m.role_tags({ role: type as string })}
|
||||
{m.role_tags({ role: type as ROLE_NAME })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string>();
|
||||
const errors = useStore(field.store, (state) => state.meta.errors);
|
||||
@@ -163,11 +161,7 @@ export function Select({
|
||||
<ShadcnSelect.Select
|
||||
name={field.name}
|
||||
value={String(field.state.value)}
|
||||
onValueChange={(value) =>
|
||||
isRole
|
||||
? field.handleChange(RoleEnum.parse(value))
|
||||
: field.handleChange(value)
|
||||
}
|
||||
onValueChange={(value) => field.handleChange(value)}
|
||||
>
|
||||
<ShadcnSelect.SelectTrigger aria-invalid={isInvalid}>
|
||||
<ShadcnSelect.SelectValue placeholder={placeholder} />
|
||||
@@ -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<string>();
|
||||
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 && <FieldError errors={errors} />}
|
||||
</Field>
|
||||
|
||||
@@ -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) => {
|
||||
<field.SelectUser
|
||||
label={m.houses_page_form_create_for()}
|
||||
values={users ?? []}
|
||||
placeholder="Chọn người dùng"
|
||||
placeholder={m.houses_page_form_user_select_placeholder()}
|
||||
searchPlaceholder={m.houses_page_form_user_select_search_placeholder()}
|
||||
keyword={userKeyword}
|
||||
onKeywordChange={setUserKeyword}
|
||||
/>
|
||||
|
||||
132
src/components/form/house/user-invite-member-form.tsx
Normal file
132
src/components/form/house/user-invite-member-form.tsx
Normal file
@@ -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 (
|
||||
<form
|
||||
id="user-invite-member-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="house_name">
|
||||
{m.houses_page_form_name()}:
|
||||
</FieldLabel>
|
||||
<div className="flex gap-2">{activeHouse.name}</div>
|
||||
</Field>
|
||||
<form.AppField name="email">
|
||||
{(field) => (
|
||||
<field.SelectUser
|
||||
label={m.houses_page_form_user()}
|
||||
values={users ?? []}
|
||||
placeholder={m.houses_page_form_user_select_placeholder()}
|
||||
searchPlaceholder={m.houses_page_form_user_select_search_placeholder()}
|
||||
keyword={userKeyword}
|
||||
onKeywordChange={setUserKeyword}
|
||||
selectKey="email"
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<form.AppField name="role">
|
||||
{(field) => (
|
||||
<field.Select
|
||||
label={m.profile_form_role()}
|
||||
placeholder={m.users_page_ui_select_placeholder_role()}
|
||||
values={[
|
||||
{ value: 'owner', label: m.role_tags({ role: 'owner' }) },
|
||||
{ value: 'admin', label: m.role_tags({ role: 'admin' }) },
|
||||
{ value: 'member', label: m.role_tags({ role: 'member' }) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton
|
||||
label={m.ui_confirm_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInviteMemberForm;
|
||||
@@ -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) => {
|
||||
<field.Select
|
||||
label={m.profile_form_role()}
|
||||
placeholder={m.users_page_ui_select_placeholder_role()}
|
||||
isRole
|
||||
values={[
|
||||
{ value: 'admin', label: m.role_tags({ role: 'admin' }) },
|
||||
{ value: 'user', label: m.role_tags({ role: 'user' }) },
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { setUserRole } from '@service/user.api';
|
||||
import { userUpdateRoleSchema } from '@service/user.schema';
|
||||
import {
|
||||
userUpdateRoleBESchema,
|
||||
userUpdateRoleSchema,
|
||||
} from '@service/user.schema';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
@@ -52,7 +55,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
||||
onSubmit: userUpdateRoleSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
updateRoleMutation({ data: value });
|
||||
updateRoleMutation({ data: userUpdateRoleBESchema.parse(value) });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
src/components/house/current-user-invitation-list.tsx
Normal file
116
src/components/house/current-user-invitation-list.tsx
Normal file
@@ -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<typeof authClient.useActiveOrganization>['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 <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border col-span-1 lg:col-span-3 shadow-xs bg-linear-to-br from-primary/5 to-card">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/2">
|
||||
{m.houses_page_ui_view_table_header_invite()}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/2"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeHouse.invitations.length > 0 ? (
|
||||
activeHouse.invitations.map((item) => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Item>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<strong>{m.houses_user_page_invite_label_to()}:</strong>{' '}
|
||||
{item.email} - <RoleBadge type={item.role} />
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
<strong>
|
||||
{m.houses_user_page_invite_label_status()}:{' '}
|
||||
{m.invite_status({
|
||||
status: item.status as INVITE_STATUS,
|
||||
})}
|
||||
</strong>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</TableCell>
|
||||
<TableCell className="p-6">
|
||||
<div className="flex justify-end gap-2">
|
||||
{item.status !== INVITE_STATUS.CANCELED && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer w-20 py-4"
|
||||
onClick={() => handleCancelInvitation(item.id)}
|
||||
>
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentUserInvitationList;
|
||||
@@ -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<typeof authClient.useActiveOrganization>['data'];
|
||||
@@ -23,7 +24,7 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border col-span-1 lg:col-span-2 shadow-xs bg-linear-to-br from-primary/5 to-card">
|
||||
<Table className="">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
|
||||
@@ -33,7 +34,11 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
|
||||
{m.houses_page_ui_view_table_header_role()}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 bg-primary" />
|
||||
<TableHead className="px-4 bg-primary">
|
||||
<div className="flex justify-end gap-2">
|
||||
<InviteUserAction />
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof authClient.useActiveOrganization>['data'];
|
||||
|
||||
@@ -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';
|
||||
|
||||
65
src/components/house/invite-user-dialog.tsx
Normal file
65
src/components/house/invite-user-dialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-full cursor-pointer bg-white text-black hover:bg-green-500 hover:text-white"
|
||||
>
|
||||
<PaperPlaneTiltIcon weight="fill" />
|
||||
{m.houses_user_page_action_invite_user()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-80 xl:max-w-lg"
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-green-600">
|
||||
<PaperPlaneTiltIcon size={16} weight="fill" />
|
||||
{m.houses_user_page_action_invite_user()}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{m.houses_user_page_action_invite_user()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<UserInviteMemberForm onSubmit={_setOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default InviteUserAction;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<CurrentUserInvitationList activeHouse={activeHouse} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 } }),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user