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_save_btn": "Save changes",
"ui_update_btn": "Update", "ui_update_btn": "Update",
"ui_delete_btn": "Delete", "ui_delete_btn": "Delete",
"ui_leave_btn": "Leave",
"ui_remove_btn": "Remove",
"ui_ban_btn": "Lock", "ui_ban_btn": "Lock",
"ui_unban_btn": "Unlock", "ui_unban_btn": "Unlock",
"ui_invite_btn": "Invite", "ui_invite_btn": "Invite",
@@ -174,6 +176,12 @@
"houses_page_form_user_select_search_placeholder": "Search by name or email...", "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_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_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_create_house_success": "Created house successfully!",
"houses_page_message_house_not_found": "House not found!", "houses_page_message_house_not_found": "House not found!",
"houses_page_message_update_house_success": "Updated house successfully!", "houses_page_message_update_house_success": "Updated house successfully!",

View File

@@ -28,7 +28,7 @@
"role=admin": "Quản lý", "role=admin": "Quản lý",
"role=user": "Người dùng", "role=user": "Người dùng",
"role=member": "Thành viên", "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_save_btn": "Lưu thay đổi",
"ui_update_btn": "Cập nhật", "ui_update_btn": "Cập nhật",
"ui_delete_btn": "Xóa", "ui_delete_btn": "Xóa",
"ui_leave_btn": "Rời đi",
"ui_remove_btn": "Xóa",
"ui_ban_btn": "Khóa", "ui_ban_btn": "Khóa",
"ui_unban_btn": "Mở khóa", "ui_unban_btn": "Mở khóa",
"ui_invite_btn": "Mời", "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_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_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_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_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_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!", "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', email: 'raysam024@gmail.com',
}, },
{ {
name: 'Raysam', name: 'Sam Miu',
email: 'juines.liu@gmail.com', email: 'juines.liu@gmail.com',
}, },
]; ];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
DialogTrigger, DialogTrigger,
} from '@ui/dialog'; } from '@ui/dialog';
import { useState } from 'react'; import { useState } from 'react';
import { Skeleton } from '../ui/skeleton';
type CreateNewHouseProp = { type CreateNewHouseProp = {
isPersonal?: boolean; isPersonal?: boolean;
@@ -24,46 +25,42 @@ const CreateNewHouse = ({
className, className,
isPersonal = false, isPersonal = false,
}: CreateNewHouseProp) => { }: CreateNewHouseProp) => {
const { hasPermission, isLoading } = useHasPermission( const { hasPermission, isLoading } = useHasPermission('house', 'create');
'house',
'create',
isPersonal,
);
const [_open, _setOpen] = useState(false); const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus(); const prevent = usePreventAutoFocus();
if (isLoading) return null; if (isLoading) {
return <Skeleton className={cn('h-7 w-23', className)} />;
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>
);
} }
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; export default CreateNewHouse;

View File

@@ -1,10 +1,11 @@
import { authClient } from '@lib/auth-client'; import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages'; import { m } from '@paraglide/messages';
import { GearIcon, PenIcon } from '@phosphor-icons/react'; import { GearIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Skeleton } from '../ui/skeleton';
import DeleteUserHouseAction from './delete-user-house-dialog'; 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 = { type CurrentUserActionGroupProps = {
oneHouse: boolean; oneHouse: boolean;
@@ -15,6 +16,10 @@ const CurrentUserActionGroup = ({
oneHouse, oneHouse,
activeHouse, activeHouse,
}: CurrentUserActionGroupProps) => { }: CurrentUserActionGroupProps) => {
if (!activeHouse) {
return <Skeleton className="col-span-1" />;
}
return ( return (
<Card className="col-span-1"> <Card className="col-span-1">
<CardHeader> <CardHeader>
@@ -24,17 +29,9 @@ const CurrentUserActionGroup = ({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-2"> <CardContent className="flex flex-row gap-2">
<EditHouseAction data={activeHouse as HouseWithMembers} isPersonal> <EditUserHouseAction data={activeHouse} />
<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>
{!oneHouse && <DeleteUserHouseAction activeHouse={activeHouse} />} {!oneHouse && <DeleteUserHouseAction activeHouse={activeHouse} />}
<LeaveHouseAction activeHouseId={activeHouse.id} />
</CardContent> </CardContent>
</Card> </Card>
); );

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ import parse from 'html-react-parser';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge'; import RoleBadge from '../avatar/role-badge';
import { Skeleton } from '../ui/skeleton';
type DeleteHouseProps = { type DeleteHouseProps = {
data: HouseWithMembers; data: HouseWithMembers;
@@ -69,93 +70,93 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
deleteHouseMutation({ data }); deleteHouseMutation({ data });
}; };
if (isLoading) return null; if (isLoading) {
return <Skeleton className="h-8 w-8 rounded-full" />;
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>
);
} }
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; export default DeleteHouseAction;

View File

@@ -32,6 +32,7 @@ import parse from 'html-react-parser';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge'; import RoleBadge from '../avatar/role-badge';
import { Skeleton } from '../ui/skeleton';
type DeleteUserHouseProps = { type DeleteUserHouseProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data']; activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
@@ -40,7 +41,11 @@ type DeleteUserHouseProps = {
const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => { const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => {
const [_open, _setOpen] = useState(false); const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus(); const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'delete'); const { hasPermission, isLoading } = useHasPermission(
'house',
'delete',
true,
);
const queryClient = useQueryClient(); 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 () => { const onConfirm = async () => {
deleteHouseMutation({ data: { id: activeHouse.id } }); deleteHouseMutation({ data: { id: activeHouse.id } });
}; };
if (hasPermission) { 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-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>
);
}
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; 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 usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { m } from '@paraglide/messages'; import { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react'; import { PenIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,62 +13,63 @@ import {
DialogTrigger, DialogTrigger,
} from '@ui/dialog'; } from '@ui/dialog';
import { Label } from '@ui/label'; import { Label } from '@ui/label';
import { Skeleton } from '@ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { useState } from 'react'; import { useState } from 'react';
type EditHouseProps = { type EditHouseProps = {
data: HouseWithMembers; data: HouseWithMembers;
children: React.ReactNode;
isPersonal?: boolean;
}; };
const EditHouseAction = ({ const EditHouseAction = ({ data }: EditHouseProps) => {
data,
children,
isPersonal = false,
}: EditHouseProps) => {
const [_open, _setOpen] = useState(false); const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus(); const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'update'); const { hasPermission, isLoading } = useHasPermission('house', 'update');
if (isLoading) return null; if (isLoading) {
return <Skeleton className="h-8 w-8 rounded-full" />;
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>
);
} }
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; 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 { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@ui/button';
import { formatters } from '@utils/formatters'; import { formatters } from '@utils/formatters';
import DeleteHouseAction from './delete-house-dialog'; import DeleteHouseAction from './delete-house-dialog';
import EditHouseAction from './edit-house-dialog'; import EditHouseAction from './edit-house-dialog';
@@ -44,17 +42,7 @@ export const houseColumns: ColumnDef<HouseWithMembers>[] = [
return ( return (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<ViewDetailHouse data={row.original} /> <ViewDetailHouse data={row.original} />
<EditHouseAction 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>
<DeleteHouseAction data={row.original} /> <DeleteHouseAction data={row.original} />
</div> </div>
); );

View File

@@ -13,6 +13,7 @@ import {
DialogTrigger, DialogTrigger,
} from '@ui/dialog'; } from '@ui/dialog';
import { useState } from 'react'; import { useState } from 'react';
import { Skeleton } from '../ui/skeleton';
type ActionProps = {}; type ActionProps = {};
@@ -25,41 +26,43 @@ const InviteUserAction = ({}: ActionProps) => {
const [_open, _setOpen] = useState(false); const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus(); const prevent = usePreventAutoFocus();
if (isLoading) return null; if (isLoading) {
return <Skeleton className="h-7 w-29.6 rounded-full" />;
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 (!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; 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 { 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 { 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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { ItemActions, ItemContent, ItemDescription, ItemTitle } from '@ui/item';
import { Spinner } from '@ui/spinner';
import { toast } from 'sonner'; import { toast } from 'sonner';
type NotifyProps = { type NotifyProps = {
@@ -21,47 +17,49 @@ const NotificationInvitation = ({ notify }: NotifyProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutate: acceptInvitationMutation } = useMutation({ const { mutate: acceptInvitationMutation, isPending: isAcceptPending } =
mutationFn: acceptInvitation, useMutation({
onSuccess: () => { mutationFn: acceptInvitation,
queryClient.invalidateQueries({ onSuccess: () => {
queryKey: [...notificationQueries.all, 'list'], queryClient.invalidateQueries({
}); queryKey: [...notificationQueries.all, 'list'],
toast.success(m.notification_page_message_invitation_success(), { });
richColors: true, toast.success(m.notification_page_message_invitation_success(), {
}); richColors: true,
}, });
onError: (error: ReturnError) => { },
console.error(error); onError: (error: ReturnError) => {
const code = error.code as Parameters< console.error(error);
typeof m.backend_message const code = error.code as Parameters<
>[0]['code']; typeof m.backend_message
toast.error(m.backend_message({ code }), { >[0]['code'];
richColors: true, toast.error(m.backend_message({ code }), {
}); richColors: true,
}, });
}); },
});
const { mutate: rejectInvitationMutation } = useMutation({ const { mutate: rejectInvitationMutation, isPending: isRejectPending } =
mutationFn: rejectInvitation, useMutation({
onSuccess: () => { mutationFn: rejectInvitation,
queryClient.invalidateQueries({ onSuccess: () => {
queryKey: [...notificationQueries.all, 'list'], queryClient.invalidateQueries({
}); queryKey: [...notificationQueries.all, 'list'],
toast.success(m.notification_page_message_invitation_rejected(), { });
richColors: true, toast.success(m.notification_page_message_invitation_rejected(), {
}); richColors: true,
}, });
onError: (error: ReturnError) => { },
console.error(error); onError: (error: ReturnError) => {
const code = error.code as Parameters< console.error(error);
typeof m.backend_message const code = error.code as Parameters<
>[0]['code']; typeof m.backend_message
toast.error(m.backend_message({ code }), { >[0]['code'];
richColors: true, toast.error(m.backend_message({ code }), {
}); richColors: true,
}, });
}); },
});
const handleAgreeAction = async () => { const handleAgreeAction = async () => {
if (notify.link) { if (notify.link) {
@@ -95,10 +93,19 @@ const NotificationInvitation = ({ notify }: NotifyProps) => {
</ItemContent> </ItemContent>
{notify.link && ( {notify.link && (
<ItemActions> <ItemActions>
<Button onClick={() => handleAgreeAction()}> <Button
onClick={() => handleAgreeAction()}
disabled={isAcceptPending}
>
{isAcceptPending && <Spinner data-icon="inline-start" />}
{m.ui_agree_btn()} {m.ui_agree_btn()}
</Button> </Button>
<Button variant="outline" onClick={() => handleRejectAction()}> <Button
variant="outline"
onClick={() => handleRejectAction()}
disabled={isRejectPending}
>
{isRejectPending && <Spinner data-icon="inline-start" />}
{m.ui_reject_btn()} {m.ui_reject_btn()}
</Button> </Button>
</ItemActions> </ItemActions>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import * as React from 'react';
import { cn } from '@lib/utils'; import { cn } from '@lib/utils';
const buttonVariants = cva( 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: { variants: {
variant: { variant: {

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import {
houseEditBESchema, houseEditBESchema,
houseListSchema, houseListSchema,
invitationCreateBESchema, invitationCreateBESchema,
removeMemberSchema,
} from './house.schema'; } from './house.schema';
import { createAuditLog, createNotification } from './repository'; import { createAuditLog, createNotification } from './repository';
@@ -347,7 +348,7 @@ export const cancelInvitation = createServerFn({ method: 'POST' })
export const acceptInvitation = createServerFn({ method: 'POST' }) export const acceptInvitation = createServerFn({ method: 'POST' })
.middleware([authMiddleware]) .middleware([authMiddleware])
.inputValidator(actionInvitationSchema) .inputValidator(actionInvitationSchema)
.handler(async ({ data }) => { .handler(async ({ data, context: { user } }) => {
try { try {
const result = await prisma.invitation.update({ const result = await prisma.invitation.update({
where: { id: data.id }, where: { id: data.id },
@@ -360,13 +361,33 @@ export const acceptInvitation = createServerFn({ method: 'POST' })
data: { link: null }, data: { link: null },
}); });
await auth.api.addMember({ const member = await auth.api.addMember({
body: { body: {
userId: notify.userId, userId: notify.userId,
organizationId: result.organizationId, organizationId: result.organizationId,
role: (result.role as 'admin' | 'owner' | 'member') || 'member', 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; return result;
@@ -404,3 +425,72 @@ export const rejectInvitation = createServerFn({ method: 'POST' })
throw { message, code }; 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({ export const actionInvitationSchema = baseInvitation.extend({
notificationId: z.string().nonempty(m.notification_page_notify_not_found()), 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()),
});