invite member to house

This commit is contained in:
2026-02-11 22:45:33 +07:00
parent 5ffdd7454a
commit ea31b61cac
27 changed files with 545 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

@@ -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'];

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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