added leave house, remove member

This commit is contained in:
2026-02-23 21:40:32 +07:00
parent 12de48d19d
commit b821260fe7
28 changed files with 861 additions and 391 deletions

View File

@@ -55,6 +55,8 @@
"ui_save_btn": "Save changes",
"ui_update_btn": "Update",
"ui_delete_btn": "Delete",
"ui_leave_btn": "Leave",
"ui_remove_btn": "Remove",
"ui_ban_btn": "Lock",
"ui_unban_btn": "Unlock",
"ui_invite_btn": "Invite",
@@ -174,6 +176,12 @@
"houses_page_form_user_select_search_placeholder": "Search by name or email...",
"houses_page_ui_dialog_alert_delete_title": "Delete house: {name}?",
"houses_page_ui_dialog_alert_delete_description": "This action cannot be undone! It will delete all related data like: <b>Box</b>, <b>Item</b>. Please think carefully!",
"houses_page_ui_dialog_alert_leave_title": "You want to leave this house?",
"houses_page_ui_dialog_alert_leave_description": "When you leave, you should contact the owner if you want to join back! Are you sure?",
"houses_page_ui_dialog_alert_remove_title": "Do you want to remove {name} from this house?",
"houses_page_ui_dialog_alert_remove_description": "Once removed, you must invite them again and wait for the member to accept!",
"houses_page_message_leave_house_success": "Leaved house successfully!",
"houses_page_message_remove_member_success": "Removed member successfully!",
"houses_page_message_create_house_success": "Created house successfully!",
"houses_page_message_house_not_found": "House not found!",
"houses_page_message_update_house_success": "Updated house successfully!",

View File

@@ -28,7 +28,7 @@
"role=admin": "Quản lý",
"role=user": "Người dùng",
"role=member": "Thành viên",
"role=owner": "Người sở hữu"
"role=owner": "Chủ nhà"
}
}
],
@@ -55,6 +55,8 @@
"ui_save_btn": "Lưu thay đổi",
"ui_update_btn": "Cập nhật",
"ui_delete_btn": "Xóa",
"ui_leave_btn": "Rời đi",
"ui_remove_btn": "Xóa",
"ui_ban_btn": "Khóa",
"ui_unban_btn": "Mở khóa",
"ui_invite_btn": "Mời",
@@ -178,6 +180,12 @@
"houses_page_form_user_select_search_placeholder": "Tìm theo tên hoặc email...",
"houses_page_ui_dialog_alert_delete_title": "Bạn muốn xóa nhà này: {name}?",
"houses_page_ui_dialog_alert_delete_description": "Thao tác này không thể hoàn tác! Nó sẽ xóa hết mọi dữ liệu liên quan như: <b>Hộp chứa</b>, <b>Vật Phẩm</b>. Xin suy tính kỹ lưỡng!",
"houses_page_ui_dialog_alert_leave_title": "Bạn muốn rời khỏi nhà này?",
"houses_page_ui_dialog_alert_leave_description": "Một khi rời khỏi, bạn phải liên hệ chủ nhà nếu muốn được thêm vào lại! Bạn chắc chắn muốn rời khỏi nhà này?",
"houses_page_ui_dialog_alert_remove_title": "Bạn muốn xóa {name} khỏi nhà?",
"houses_page_ui_dialog_alert_remove_description": "Một khi bị xóa, bạn phải mời lại và đợi thành viên đồng ý!",
"houses_page_message_leave_house_success": "Rời khỏi nhà thành công!",
"houses_page_message_remove_member_success": "Xóa thành viên thành công!",
"houses_page_message_create_house_success": "Tạo nhà thành công!",
"houses_page_message_house_not_found": "Không tìm thấy nhà này!",
"houses_page_message_update_house_success": "Cập nhật nhà thành công!",

View File

@@ -22,7 +22,7 @@ export const userData = [
email: 'raysam024@gmail.com',
},
{
name: 'Raysam',
name: 'Sam Miu',
email: 'juines.liu@gmail.com',
},
];

View File

@@ -5,7 +5,7 @@ import Notification from './Notification';
import RouterBreadcrumb from './sidebar/router-breadcrumb';
export default function Header() {
const { data: session } = useAuth();
const { session } = useAuth();
return (
<>

View File

@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { createContext, useContext, useMemo } from 'react';
export type UserContext = {
data: ClientSession;
session: ClientSession;
isAuth: boolean;
isAdmin: boolean;
isPending: boolean;
@@ -18,7 +18,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const contextSession: UserContext = useMemo(
() => ({
data: session as ClientSession,
session: session as ClientSession,
isPending,
error,
isAuth: !!session,

View File

@@ -9,7 +9,7 @@ interface AvatarUserProps {
}
const AvatarUser = ({ className, textSize = 'md' }: AvatarUserProps) => {
const { data: session } = useAuth();
const { session } = useAuth();
const imagePath = session?.user?.image
? new URL(`../../../data/avatar/${session?.user?.image}`, import.meta.url)
.href

View File

@@ -21,7 +21,7 @@ const defaultValues: ProfileInput = {
const ProfileForm = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: session, isPending } = useAuth();
const { session, isPending } = useAuth();
const queryClient = useQueryClient();
const { mutate: updateProfileMutation, isPending: isRunning } = useMutation({

View File

@@ -21,7 +21,7 @@ type FormProps = {
};
const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => {
const { data: session } = useAuth();
const { session } = useAuth();
const [userKeyword, setUserKeyword] = useState('');
const debouncedUserKeyword = useDebounced(userKeyword, 300);
const { data: users } = useQuery(

View File

@@ -14,6 +14,7 @@ import {
DialogTrigger,
} from '@ui/dialog';
import { useState } from 'react';
import { Skeleton } from '../ui/skeleton';
type CreateNewHouseProp = {
isPersonal?: boolean;
@@ -24,46 +25,42 @@ const CreateNewHouse = ({
className,
isPersonal = false,
}: CreateNewHouseProp) => {
const { hasPermission, isLoading } = useHasPermission(
'house',
'create',
isPersonal,
);
const { hasPermission, isLoading } = useHasPermission('house', 'create');
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
if (isLoading) return null;
if (hasPermission) {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default" className={cn(className)}>
<PlusIcon />
{m.nav_add_new()}
</Button>
</DialogTrigger>
<DialogContent
className="max-w-80 xl:max-w-xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-primary">
<PlusIcon size={16} />
{m.nav_add_new()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.nav_add_new()}
</DialogDescription>
</DialogHeader>
<CreateNewHouseForm onSubmit={_setOpen} isPersonal={isPersonal} />
</DialogContent>
</Dialog>
);
if (isLoading) {
return <Skeleton className={cn('h-7 w-23', className)} />;
}
return null;
if (!hasPermission) return null;
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default" className={cn(className)}>
<PlusIcon />
{m.nav_add_new()}
</Button>
</DialogTrigger>
<DialogContent
className="max-w-80 xl:max-w-xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-primary">
<PlusIcon size={16} />
{m.nav_add_new()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.nav_add_new()}
</DialogDescription>
</DialogHeader>
<CreateNewHouseForm onSubmit={_setOpen} isPersonal={isPersonal} />
</DialogContent>
</Dialog>
);
};
export default CreateNewHouse;

View File

@@ -1,10 +1,11 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { GearIcon, PenIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import { GearIcon } from '@phosphor-icons/react';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Skeleton } from '../ui/skeleton';
import DeleteUserHouseAction from './delete-user-house-dialog';
import EditHouseAction from './edit-house-dialog';
import EditUserHouseAction from './edit-user-house-dialog';
import LeaveHouseAction from './leave-house-dialog';
type CurrentUserActionGroupProps = {
oneHouse: boolean;
@@ -15,6 +16,10 @@ const CurrentUserActionGroup = ({
oneHouse,
activeHouse,
}: CurrentUserActionGroupProps) => {
if (!activeHouse) {
return <Skeleton className="col-span-1" />;
}
return (
<Card className="col-span-1">
<CardHeader>
@@ -24,17 +29,9 @@ const CurrentUserActionGroup = ({
</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-2">
<EditHouseAction data={activeHouse as HouseWithMembers} isPersonal>
<Button
type="button"
size="icon-lg"
className="rounded-full cursor-pointer bg-blue-500 text-white hover:bg-blue-100 hover:text-blue-600"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button>
</EditHouseAction>
<EditUserHouseAction data={activeHouse} />
{!oneHouse && <DeleteUserHouseAction activeHouse={activeHouse} />}
<LeaveHouseAction activeHouseId={activeHouse.id} />
</CardContent>
</Card>
);

View File

@@ -53,8 +53,10 @@ const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
cancelInvitationMutation({ data: { id } });
};
if (!activeHouse) {
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
if (!activeHouse || isLoading) {
return (
<Skeleton className="col-span-1 lg:col-span-3 h-80 w-full rounded-xl" />
);
}
return (

View File

@@ -10,16 +10,20 @@ import {
TableHeader,
TableRow,
} from '@ui/table';
import { useAuth } from '../auth/auth-provider';
import RoleBadge from '../avatar/role-badge';
import InviteUserAction from './invite-user-dialog';
import RemoveUserFormHouse from './remove-user-form-house';
type CurrentUserMemberListProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
const { session } = useAuth();
if (!activeHouse) {
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
return <Skeleton className="col-span-1 lg:col-span-2 h-80 rounded-xl" />;
}
return (
@@ -57,7 +61,10 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
</TableCell>
<TableCell className="px-4">
<div className="flex justify-end gap-2">
{activeHouse.color}
{member.role !== 'owner' &&
session.user.id !== member.user.id && (
<RemoveUserFormHouse member={member} />
)}
</div>
</TableCell>
</TableRow>

View File

@@ -31,6 +31,7 @@ import parse from 'html-react-parser';
import { useState } from 'react';
import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge';
import { Skeleton } from '../ui/skeleton';
type DeleteHouseProps = {
data: HouseWithMembers;
@@ -69,93 +70,93 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
deleteHouseMutation({ data });
};
if (isLoading) return null;
if (hasPermission) {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-red-500 hover:bg-red-100 hover:text-red-600"
>
<TrashIcon size={16} />
<span className="sr-only">{m.ui_delete_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_delete_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<ShieldWarningIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_delete_title({ name: data.name })}
</DialogTitle>
<DialogDescription className="text-red-500">
{parse(m.houses_page_ui_dialog_alert_delete_description())}
</DialogDescription>
</DialogHeader>
<div className="overflow-hidden rounded-md border">
<Table className="bg-white">
<TableHeader>
<TableRow>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_email()}
</TableHead>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_role()}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.members.map((member) => (
<TableRow key={member.user.email}>
<TableCell>{member.user.email}</TableCell>
<TableCell>
<RoleBadge type={member.role} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (isLoading) {
return <Skeleton className="h-8 w-8 rounded-full" />;
}
return null;
if (!hasPermission) return null;
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-red-500 hover:bg-red-100 hover:text-red-600"
>
<TrashIcon size={16} />
<span className="sr-only">{m.ui_delete_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_delete_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<ShieldWarningIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_delete_title({ name: data.name })}
</DialogTitle>
<DialogDescription className="text-red-500">
{parse(m.houses_page_ui_dialog_alert_delete_description())}
</DialogDescription>
</DialogHeader>
<div className="overflow-hidden rounded-md border">
<Table className="bg-white">
<TableHeader>
<TableRow>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_email()}
</TableHead>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_role()}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.members.map((member) => (
<TableRow key={member.user.email}>
<TableCell>{member.user.email}</TableCell>
<TableCell>
<RoleBadge type={member.role} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteHouseAction;

View File

@@ -32,6 +32,7 @@ import parse from 'html-react-parser';
import { useState } from 'react';
import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge';
import { Skeleton } from '../ui/skeleton';
type DeleteUserHouseProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
@@ -40,7 +41,11 @@ type DeleteUserHouseProps = {
const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'delete');
const { hasPermission, isLoading } = useHasPermission(
'house',
'delete',
true,
);
const queryClient = useQueryClient();
@@ -66,98 +71,98 @@ const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => {
},
});
if (isLoading || !activeHouse) return null;
if (isLoading || !activeHouse) {
return <Skeleton className="h-8 w-8 rounded-full" />;
}
const onConfirm = async () => {
deleteHouseMutation({ data: { id: activeHouse.id } });
};
if (hasPermission) {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
size="icon-lg"
className="rounded-full cursor-pointer bg-red-500 text-white hover:bg-red-100 hover:text-red-600"
>
<TrashIcon size={16} />
<span className="sr-only">{m.ui_delete_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_delete_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<ShieldWarningIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_delete_title({
name: activeHouse.name,
})}
</DialogTitle>
<DialogDescription className="text-red-500">
{parse(m.houses_page_ui_dialog_alert_delete_description())}
</DialogDescription>
</DialogHeader>
<div className="overflow-hidden rounded-md border">
<Table className="bg-white">
<TableHeader>
<TableRow>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_email()}
</TableHead>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_role()}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeHouse.members.map((member) => (
<TableRow key={member.user.email}>
<TableCell>{member.user.email}</TableCell>
<TableCell>
<RoleBadge type={member.role} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
if (!hasPermission) return null;
return null;
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
size="icon-lg"
className="rounded-full cursor-pointer bg-red-500 text-white hover:bg-red-100 hover:text-red-600"
>
<TrashIcon size={16} />
<span className="sr-only">{m.ui_delete_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_delete_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<ShieldWarningIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_delete_title({
name: activeHouse.name,
})}
</DialogTitle>
<DialogDescription className="text-red-500">
{parse(m.houses_page_ui_dialog_alert_delete_description())}
</DialogDescription>
</DialogHeader>
<div className="overflow-hidden rounded-md border">
<Table className="bg-white">
<TableHeader>
<TableRow>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_email()}
</TableHead>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_role()}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeHouse.members.map((member) => (
<TableRow key={member.user.email}>
<TableCell>{member.user.email}</TableCell>
<TableCell>
<RoleBadge type={member.role} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteUserHouseAction;

View File

@@ -3,6 +3,7 @@ import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import {
Dialog,
DialogContent,
@@ -12,62 +13,63 @@ import {
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import { Skeleton } from '@ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { useState } from 'react';
type EditHouseProps = {
data: HouseWithMembers;
children: React.ReactNode;
isPersonal?: boolean;
};
const EditHouseAction = ({
data,
children,
isPersonal = false,
}: EditHouseProps) => {
const EditHouseAction = ({ data }: EditHouseProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'update');
if (isLoading) return null;
if (hasPermission) {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>{children}</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
<Label>{m.ui_edit_house_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-blue-600">
<PenIcon size={16} />
{m.ui_edit_house_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_edit_house_btn()}
</DialogDescription>
</DialogHeader>
<EditHouseForm
data={data}
onSubmit={_setOpen}
mutateKey={isPersonal ? 'currentUser' : 'list'}
/>
</DialogContent>
</Dialog>
);
if (isLoading) {
return <Skeleton className="h-8 w-8 rounded-full" />;
}
return null;
if (!hasPermission) return null;
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
<Label>{m.ui_edit_house_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-blue-600">
<PenIcon size={16} />
{m.ui_edit_house_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_edit_house_btn()}
</DialogDescription>
</DialogHeader>
<EditHouseForm data={data} onSubmit={_setOpen} mutateKey="list" />
</DialogContent>
</Dialog>
);
};
export default EditHouseAction;

View File

@@ -0,0 +1,83 @@
import EditHouseForm from '@form/house/admin-edit-house-form';
import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import { Skeleton } from '@ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { useState } from 'react';
type EditUserHouseProps = {
data: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const EditUserHouseAction = ({ data }: EditUserHouseProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission(
'house',
'update',
true,
);
if (isLoading) {
return <Skeleton className="h-8 w-8 rounded-full" />;
}
if (!hasPermission) return null;
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
size="icon-lg"
className="rounded-full cursor-pointer bg-blue-500 text-white hover:bg-blue-100 hover:text-blue-600"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
<Label>{m.ui_edit_house_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-blue-600">
<PenIcon size={16} />
{m.ui_edit_house_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_edit_house_btn()}
</DialogDescription>
</DialogHeader>
<EditHouseForm
data={data as HouseWithMembers}
onSubmit={_setOpen}
mutateKey="currentUser"
/>
</DialogContent>
</Dialog>
);
};
export default EditUserHouseAction;

View File

@@ -1,7 +1,5 @@
import { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@ui/button';
import { formatters } from '@utils/formatters';
import DeleteHouseAction from './delete-house-dialog';
import EditHouseAction from './edit-house-dialog';
@@ -44,17 +42,7 @@ export const houseColumns: ColumnDef<HouseWithMembers>[] = [
return (
<div className="flex justify-end gap-2">
<ViewDetailHouse data={row.original} />
<EditHouseAction data={row.original}>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button>
</EditHouseAction>
<EditHouseAction data={row.original} />
<DeleteHouseAction data={row.original} />
</div>
);

View File

@@ -13,6 +13,7 @@ import {
DialogTrigger,
} from '@ui/dialog';
import { useState } from 'react';
import { Skeleton } from '../ui/skeleton';
type ActionProps = {};
@@ -25,41 +26,43 @@ const InviteUserAction = ({}: ActionProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
if (isLoading) return null;
if (hasPermission) {
return (
<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>
);
if (isLoading) {
return <Skeleton className="h-7 w-29.6 rounded-full" />;
}
if (!hasPermission) return null;
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

@@ -0,0 +1,120 @@
import useHasPermission from '@/hooks/use-has-permission';
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { leaveHouse } from '@/service/house.api';
import { FootprintsIcon } from '@phosphor-icons/react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '@ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import { Skeleton } from '@ui/skeleton';
import { Spinner } from '@ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { useState } from 'react';
import { toast } from 'sonner';
type LeaveHouseProps = {
activeHouseId: string;
};
const LeaveHouseAction = ({ activeHouseId }: LeaveHouseProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'leave', true);
const { mutate: leaveHouseMutation, isPending } = useMutation({
mutationFn: leaveHouse,
onSuccess: () => {
toast.success(m.houses_page_message_leave_house_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
if (isLoading || !activeHouseId) {
return <Skeleton className="h-8 w-8 rounded-full" />;
}
const onConfirm = async () => {
leaveHouseMutation({ data: { id: activeHouseId } });
};
if (!hasPermission) return null;
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="rounded-full cursor-pointer bg-red-500 text-white hover:bg-red-100 hover:text-red-600"
>
<FootprintsIcon size={16} />
<span className="sr-only">{m.ui_leave_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_leave_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<FootprintsIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_leave_title()}
</DialogTitle>
<DialogDescription className="text-red-500 text-sm">
{m.houses_page_ui_dialog_alert_leave_description()}
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default LeaveHouseAction;

View File

@@ -0,0 +1,136 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages';
import { removeMember } from '@/service/house.api';
import { housesQueries } from '@/service/queries';
import { UserCircleMinusIcon } from '@phosphor-icons/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import { Spinner } from '@ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { useState } from 'react';
import { toast } from 'sonner';
type RemoveUserFormProps = {
member: {
id: string;
organizationId: string;
userId: string;
user: {
email: string;
name: string;
};
};
};
const RemoveUserFormHouse = ({ member }: RemoveUserFormProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { refetch } = authClient.useActiveOrganization();
const queryClient = useQueryClient();
const { mutate: removeMemberMutation, isPending } = useMutation({
mutationFn: removeMember,
onSuccess: () => {
refetch();
queryClient.invalidateQueries({
queryKey: [...housesQueries.all, 'currentUser'],
});
_setOpen(false);
toast.success(m.houses_page_message_remove_member_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const onConfirm = () => {
removeMemberMutation({
data: {
houseId: member.organizationId,
memberId: member.id,
},
});
};
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="rounded-full cursor-pointer bg-red-500 text-white hover:bg-red-100 hover:text-red-600"
>
<UserCircleMinusIcon size={16} />
<span className="sr-only">{m.ui_remove_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_remove_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<UserCircleMinusIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_remove_title({
name: member.user.name,
})}
</DialogTitle>
<DialogDescription className="text-red-500 text-sm">
{m.houses_page_ui_dialog_alert_remove_description()}
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default RemoveUserFormHouse;

View File

@@ -1,15 +1,11 @@
import { Button } from '@/components/ui/button';
import {
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@/components/ui/item';
import { m } from '@/paraglide/messages';
import { acceptInvitation, rejectInvitation } from '@/service/house.api';
import { notificationQueries } from '@/service/queries';
import { formatTimeAgo } from '@/utils/helper';
import { m } from '@paraglide/messages';
import { acceptInvitation, rejectInvitation } from '@service/house.api';
import { notificationQueries } from '@service/queries';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ItemActions, ItemContent, ItemDescription, ItemTitle } from '@ui/item';
import { Spinner } from '@ui/spinner';
import { toast } from 'sonner';
type NotifyProps = {
@@ -21,47 +17,49 @@ const NotificationInvitation = ({ notify }: NotifyProps) => {
const queryClient = useQueryClient();
const { mutate: acceptInvitationMutation } = useMutation({
mutationFn: acceptInvitation,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...notificationQueries.all, 'list'],
});
toast.success(m.notification_page_message_invitation_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const { mutate: acceptInvitationMutation, isPending: isAcceptPending } =
useMutation({
mutationFn: acceptInvitation,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...notificationQueries.all, 'list'],
});
toast.success(m.notification_page_message_invitation_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const { mutate: rejectInvitationMutation } = useMutation({
mutationFn: rejectInvitation,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...notificationQueries.all, 'list'],
});
toast.success(m.notification_page_message_invitation_rejected(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const { mutate: rejectInvitationMutation, isPending: isRejectPending } =
useMutation({
mutationFn: rejectInvitation,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...notificationQueries.all, 'list'],
});
toast.success(m.notification_page_message_invitation_rejected(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const handleAgreeAction = async () => {
if (notify.link) {
@@ -95,10 +93,19 @@ const NotificationInvitation = ({ notify }: NotifyProps) => {
</ItemContent>
{notify.link && (
<ItemActions>
<Button onClick={() => handleAgreeAction()}>
<Button
onClick={() => handleAgreeAction()}
disabled={isAcceptPending}
>
{isAcceptPending && <Spinner data-icon="inline-start" />}
{m.ui_agree_btn()}
</Button>
<Button variant="outline" onClick={() => handleRejectAction()}>
<Button
variant="outline"
onClick={() => handleRejectAction()}
disabled={isRejectPending}
>
{isRejectPending && <Spinner data-icon="inline-start" />}
{m.ui_reject_btn()}
</Button>
</ItemActions>

View File

@@ -114,6 +114,9 @@ const NavMain = () => {
to={item.path}
className="cursor-pointer"
tooltip={`${nav.title} - ${item.title}`}
activeProps={{
className: 'bg-primary text-white',
}}
>
<Icon size={24} />
{item.title}

View File

@@ -35,7 +35,7 @@ const NavUser = () => {
const navigate = useNavigate();
const { isMobile } = useSidebar();
const queryClient = useQueryClient();
const { data: session } = useAuth();
const { session } = useAuth();
const signout = async () => {
await authClient.signOut({

View File

@@ -5,7 +5,7 @@ import * as React from 'react';
import { cn } from '@lib/utils';
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white disabled:data-[dot=true]:opacity-100 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white disabled:data-[dot=true]:opacity-100 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
{
variants: {
variant: {

View File

@@ -6,11 +6,19 @@ function useHasPermission(
action: string,
houseCheck: boolean = false,
) {
const { data: activeOrganization } = authClient.useActiveOrganization();
const [hasPermission, setHasPermission] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (houseCheck && !activeOrganization?.id) {
setIsLoading(false);
setHasPermission(false);
return;
}
const checkPermission = async () => {
setIsLoading(true);
try {
const option = {
permissions: {
@@ -29,7 +37,7 @@ function useHasPermission(
}
};
checkPermission();
}, [resource, action]);
}, [resource, action, houseCheck, activeOrganization?.id]);
return { hasPermission, isLoading };
}

View File

@@ -1,37 +1,37 @@
import { createAccessControl } from 'better-auth/plugins/access'
import { createAccessControl } from 'better-auth/plugins/access';
import {
defaultStatements,
adminAc,
defaultStatements,
ownerAc,
} from 'better-auth/plugins/organization/access'
} from 'better-auth/plugins/organization/access';
const statement = {
...defaultStatements,
house: ['list', 'create', 'update', 'delete'],
house: ['list', 'create', 'update', 'delete', 'leave'],
box: ['list', 'create', 'update', 'delete'],
item: ['list', 'create', 'update', 'delete'],
} as const
} as const;
const acOrg = createAccessControl(statement)
const acOrg = createAccessControl(statement);
const owner = acOrg.newRole({
...ownerAc.statements,
house: ['list', 'create', 'update', 'delete'],
box: ['list', 'create', 'update', 'delete'],
item: ['list', 'create', 'update', 'delete'],
})
});
const adminOrg = acOrg.newRole({
...adminAc.statements,
house: ['list', 'create', 'update', 'delete'],
house: ['list', 'create', 'update', 'leave'],
box: ['list', 'create', 'update', 'delete'],
item: ['list', 'create', 'update', 'delete'],
})
});
const member = acOrg.newRole({
house: ['list', 'create', 'update', 'delete'],
box: ['list', 'create', 'update', 'delete'],
house: ['list', 'leave'],
box: ['list'],
item: ['list', 'create', 'update', 'delete'],
})
});
export { acOrg, owner, adminOrg, member }
export { acOrg, adminOrg, member, owner };

View File

@@ -13,6 +13,7 @@ import {
houseEditBESchema,
houseListSchema,
invitationCreateBESchema,
removeMemberSchema,
} from './house.schema';
import { createAuditLog, createNotification } from './repository';
@@ -347,7 +348,7 @@ export const cancelInvitation = createServerFn({ method: 'POST' })
export const acceptInvitation = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(actionInvitationSchema)
.handler(async ({ data }) => {
.handler(async ({ data, context: { user } }) => {
try {
const result = await prisma.invitation.update({
where: { id: data.id },
@@ -360,13 +361,33 @@ export const acceptInvitation = createServerFn({ method: 'POST' })
data: { link: null },
});
await auth.api.addMember({
const member = await auth.api.addMember({
body: {
userId: notify.userId,
organizationId: result.organizationId,
role: (result.role as 'admin' | 'owner' | 'member') || 'member',
},
});
await createAuditLog({
action: LOG_ACTION.UPDATE,
tableName: DB_TABLE.INVITATION,
recordId: result.id,
oldValue: JSON.stringify(result),
newValue: 'Accept Invitation / Đồng ý mời',
userId: user.id,
});
if (member) {
await createAuditLog({
action: LOG_ACTION.CREATE,
tableName: DB_TABLE.MEMBER,
recordId: member.id,
oldValue: '',
newValue: JSON.stringify(member),
userId: user.id,
});
}
}
return result;
@@ -404,3 +425,72 @@ export const rejectInvitation = createServerFn({ method: 'POST' })
throw { message, code };
}
});
export const leaveHouse = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(baseHouse)
.handler(async ({ data }) => {
try {
const headers = getRequestHeaders();
const result = await auth.api.leaveOrganization({
body: {
organizationId: data.id,
},
headers,
});
if (result) {
await createAuditLog({
action: LOG_ACTION.UPDATE,
tableName: DB_TABLE.ORGANIZATION,
recordId: result.id,
oldValue: JSON.stringify(result),
newValue: `${result.user.name} đã rời khỏi nhà (left the house)`,
userId: result.user.id,
});
}
return result;
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});
export const removeMember = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(removeMemberSchema)
.handler(async ({ data, context: { user } }) => {
try {
const headers = getRequestHeaders();
const result = await auth.api.removeMember({
body: {
memberIdOrEmail: data.memberId,
organizationId: data.houseId,
},
headers,
});
if (result) {
const oldMember = await prisma.user.findUnique({
where: { id: result.member.userId },
});
await createAuditLog({
action: LOG_ACTION.UPDATE,
tableName: DB_TABLE.MEMBER,
recordId: result.member.id,
oldValue: JSON.stringify(result),
newValue: `${oldMember?.name} đã bị "mời" rời khỏi nhà (was "asked" to leave the house.)`,
userId: user.id,
});
}
return result;
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});

View File

@@ -71,3 +71,8 @@ export const invitationCreateBESchema = invitationCreateSchema.extend({
export const actionInvitationSchema = baseInvitation.extend({
notificationId: z.string().nonempty(m.notification_page_notify_not_found()),
});
export const removeMemberSchema = z.object({
memberId: z.string().nonempty(m.users_page_message_user_not_found()),
houseId: z.string().nonempty(m.houses_page_message_house_not_found()),
});