B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { DataTable } from '@kit/ui/data-table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { RemoveMemberDialog } from './remove-member-dialog';
|
||||
import { RoleBadge } from './role-badge';
|
||||
import { TransferOwnershipDialog } from './transfer-ownership-dialog';
|
||||
import { UpdateMemberRoleDialog } from './update-member-role-dialog';
|
||||
|
||||
type Members =
|
||||
Database['public']['Functions']['get_account_members']['Returns'];
|
||||
|
||||
interface Permissions {
|
||||
canUpdateRole: (roleHierarchy: number) => boolean;
|
||||
canRemoveFromAccount: (roleHierarchy: number) => boolean;
|
||||
canTransferOwnership: boolean;
|
||||
}
|
||||
|
||||
type AccountMembersTableProps = {
|
||||
members: Members;
|
||||
currentUserId: string;
|
||||
currentAccountId: string;
|
||||
userRoleHierarchy: number;
|
||||
isPrimaryOwner: boolean;
|
||||
canManageRoles: boolean;
|
||||
};
|
||||
|
||||
export function AccountMembersTable({
|
||||
members,
|
||||
currentUserId,
|
||||
currentAccountId,
|
||||
isPrimaryOwner,
|
||||
userRoleHierarchy,
|
||||
canManageRoles,
|
||||
}: AccountMembersTableProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
const permissions = {
|
||||
canUpdateRole: (targetRole: number) => {
|
||||
return (
|
||||
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
|
||||
);
|
||||
},
|
||||
canRemoveFromAccount: (targetRole: number) => {
|
||||
return (
|
||||
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
|
||||
);
|
||||
},
|
||||
canTransferOwnership: isPrimaryOwner,
|
||||
};
|
||||
|
||||
const columns = useGetColumns(permissions, {
|
||||
currentUserId,
|
||||
currentAccountId,
|
||||
currentRoleHierarchy: userRoleHierarchy,
|
||||
});
|
||||
|
||||
const filteredMembers = members
|
||||
.filter((member) => {
|
||||
const searchString = search.toLowerCase();
|
||||
|
||||
const displayName = (
|
||||
member.name ??
|
||||
member.email.split('@')[0] ??
|
||||
''
|
||||
).toLowerCase();
|
||||
|
||||
return (
|
||||
displayName.includes(searchString) ||
|
||||
member.role.toLowerCase().includes(searchString)
|
||||
);
|
||||
})
|
||||
.sort((prev, next) => {
|
||||
if (prev.primary_owner_user_id === prev.user_id) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (prev.role_hierarchy_level < next.role_hierarchy_level) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<Input
|
||||
value={search}
|
||||
onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
|
||||
placeholder={t(`searchMembersPlaceholder`)}
|
||||
/>
|
||||
|
||||
<DataTable columns={columns} data={filteredMembers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useGetColumns(
|
||||
permissions: Permissions,
|
||||
params: {
|
||||
currentUserId: string;
|
||||
currentAccountId: string;
|
||||
currentRoleHierarchy: number;
|
||||
},
|
||||
): ColumnDef<Members[0]>[] {
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
header: t('memberName'),
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const member = row.original;
|
||||
const displayName = member.name ?? member.email.split('@')[0];
|
||||
const isSelf = member.user_id === params.currentUserId;
|
||||
|
||||
return (
|
||||
<span className={'flex items-center space-x-4 text-left'}>
|
||||
<span>
|
||||
<ProfileAvatar
|
||||
displayName={displayName}
|
||||
pictureUrl={member.picture_url}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span>{displayName}</span>
|
||||
|
||||
<If condition={isSelf}>
|
||||
<Badge variant={'outline'}>{t('youLabel')}</Badge>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('emailLabel'),
|
||||
accessorKey: 'email',
|
||||
cell: ({ row }) => {
|
||||
return row.original.email ?? '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('roleLabel'),
|
||||
cell: ({ row }) => {
|
||||
const { role, primary_owner_user_id, user_id } = row.original;
|
||||
const isPrimaryOwner = primary_owner_user_id === user_id;
|
||||
|
||||
return (
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<RoleBadge role={role} />
|
||||
|
||||
<If condition={isPrimaryOwner}>
|
||||
<span
|
||||
className={
|
||||
'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium dark:text-black'
|
||||
}
|
||||
>
|
||||
{t('primaryOwnerLabel')}
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('joinedAtLabel'),
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.created_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<ActionsDropdown
|
||||
permissions={permissions}
|
||||
member={row.original}
|
||||
currentUserId={params.currentUserId}
|
||||
currentTeamAccountId={params.currentAccountId}
|
||||
currentRoleHierarchy={params.currentRoleHierarchy}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, params, permissions],
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsDropdown({
|
||||
permissions,
|
||||
member,
|
||||
currentUserId,
|
||||
currentTeamAccountId,
|
||||
currentRoleHierarchy,
|
||||
}: {
|
||||
permissions: Permissions;
|
||||
member: Members[0];
|
||||
currentUserId: string;
|
||||
currentTeamAccountId: string;
|
||||
currentRoleHierarchy: number;
|
||||
}) {
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||
|
||||
const isCurrentUser = member.user_id === currentUserId;
|
||||
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
|
||||
|
||||
if (isCurrentUser || isPrimaryOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memberRoleHierarchy = member.role_hierarchy_level;
|
||||
const canUpdateRole = permissions.canUpdateRole(memberRoleHierarchy);
|
||||
|
||||
const canRemoveFromAccount =
|
||||
permissions.canRemoveFromAccount(memberRoleHierarchy);
|
||||
|
||||
// if has no permission to update role, transfer ownership or remove from account
|
||||
// do not render the dropdown menu
|
||||
if (
|
||||
!canUpdateRole &&
|
||||
!permissions.canTransferOwnership &&
|
||||
!canRemoveFromAccount
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'} size={'icon'}>
|
||||
<Ellipsis className={'h-5 w-5'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={canUpdateRole}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
<Trans i18nKey={'teams:updateRole'} />
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canTransferOwnership}>
|
||||
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
|
||||
<Trans i18nKey={'teams:transferOwnership'} />
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={canRemoveFromAccount}>
|
||||
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
|
||||
<Trans i18nKey={'teams:removeMember'} />
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<If condition={isRemoving}>
|
||||
<RemoveMemberDialog
|
||||
isOpen
|
||||
setIsOpen={setIsRemoving}
|
||||
teamAccountId={currentTeamAccountId}
|
||||
userId={member.user_id}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isUpdatingRole}>
|
||||
<UpdateMemberRoleDialog
|
||||
isOpen
|
||||
setIsOpen={setIsUpdatingRole}
|
||||
userId={member.user_id}
|
||||
userRole={member.role}
|
||||
teamAccountId={currentTeamAccountId}
|
||||
userRoleHierarchy={currentRoleHierarchy}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isTransferring}>
|
||||
<TransferOwnershipDialog
|
||||
isOpen
|
||||
setIsOpen={setIsTransferring}
|
||||
targetDisplayName={member.name ?? member.email}
|
||||
accountId={member.account_id}
|
||||
userId={member.user_id}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
|
||||
import { MembershipRoleSelector } from './membership-role-selector';
|
||||
import { RolesDataProvider } from './roles-data-provider';
|
||||
|
||||
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
|
||||
|
||||
type Role = string;
|
||||
|
||||
/**
|
||||
* The maximum number of invites that can be sent at once.
|
||||
* Useful to avoid spamming the server with too large payloads
|
||||
*/
|
||||
const MAX_INVITES = 5;
|
||||
|
||||
export function InviteMembersDialogContainer({
|
||||
accountSlug,
|
||||
userRoleHierarchy,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
accountSlug: string;
|
||||
userRoleHierarchy: number;
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'teams:inviteMembersHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'teams:inviteMembersDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
{(roles) => (
|
||||
<InviteMembersForm
|
||||
pending={pending}
|
||||
roles={roles}
|
||||
onSubmit={(data) => {
|
||||
startTransition(() => {
|
||||
const promise = createInvitationsAction({
|
||||
accountSlug,
|
||||
invitations: data.invitations,
|
||||
});
|
||||
|
||||
toast.promise(() => promise, {
|
||||
loading: t('invitingMembers'),
|
||||
success: t('inviteMembersSuccessMessage'),
|
||||
error: t('inviteMembersErrorMessage'),
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RolesDataProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteMembersForm({
|
||||
onSubmit,
|
||||
roles,
|
||||
pending,
|
||||
}: {
|
||||
onSubmit: (data: { invitations: InviteModel[] }) => void;
|
||||
pending: boolean;
|
||||
roles: string[];
|
||||
}) {
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(InviteMembersSchema),
|
||||
shouldUseNativeValidation: true,
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
invitations: [createEmptyInviteModel()],
|
||||
},
|
||||
});
|
||||
|
||||
const fieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'invitations',
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-8'}
|
||||
data-test={'invite-members-form'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{fieldArray.fields.map((field, index) => {
|
||||
const isFirst = index === 0;
|
||||
|
||||
const emailInputName = `invitations.${index}.email` as const;
|
||||
const roleInputName = `invitations.${index}.role` as const;
|
||||
|
||||
return (
|
||||
<div data-test={'invite-member-form-item'} key={field.id}>
|
||||
<div className={'flex items-end gap-x-1 md:space-x-2'}>
|
||||
<div className={'w-7/12'}>
|
||||
<FormField
|
||||
name={emailInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<If condition={isFirst}>
|
||||
<FormLabel>{t('emailLabel')}</FormLabel>
|
||||
</If>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'invite-email-input'}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
type="email"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'w-4/12'}>
|
||||
<FormField
|
||||
name={roleInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<If condition={isFirst}>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'teams:roleLabel'} />
|
||||
</FormLabel>
|
||||
</If>
|
||||
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
triggerClassName={'m-0'}
|
||||
roles={roles}
|
||||
value={field.value}
|
||||
onChange={(role) => {
|
||||
form.setValue(field.name, role);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-[40px] items-end justify-end'}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
type={'button'}
|
||||
disabled={fieldArray.fields.length <= 1}
|
||||
data-test={'remove-invite-button'}
|
||||
aria-label={t('removeInviteButtonLabel')}
|
||||
onClick={() => {
|
||||
fieldArray.remove(index);
|
||||
form.clearErrors(emailInputName);
|
||||
}}
|
||||
>
|
||||
<X className={'h-4 lg:h-5'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
{t('removeInviteButtonLabel')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<If condition={fieldArray.fields.length < MAX_INVITES}>
|
||||
<div>
|
||||
<Button
|
||||
data-test={'add-new-invite-button'}
|
||||
type={'button'}
|
||||
variant={'link'}
|
||||
size={'sm'}
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
fieldArray.append(createEmptyInviteModel());
|
||||
}}
|
||||
>
|
||||
<Plus className={'mr-1 h-3'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<Button type={'submit'} disabled={pending}>
|
||||
<Trans
|
||||
i18nKey={
|
||||
pending
|
||||
? 'teams:invitingMembers'
|
||||
: 'teams:inviteMembersButtonLabel'
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function createEmptyInviteModel() {
|
||||
return { email: '', role: 'member' as Role };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Role = string;
|
||||
|
||||
export function MembershipRoleSelector({
|
||||
roles,
|
||||
value,
|
||||
currentUserRole,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: {
|
||||
roles: Role[];
|
||||
value: Role;
|
||||
currentUserRole?: Role;
|
||||
onChange: (role: Role) => unknown;
|
||||
triggerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
className={triggerClassName}
|
||||
data-test={'role-selector-trigger'}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{roles.map((role) => {
|
||||
return (
|
||||
<SelectItem
|
||||
key={role}
|
||||
data-test={`role-option-${role}`}
|
||||
disabled={currentUserRole === role}
|
||||
value={role}
|
||||
>
|
||||
<span className={'text-sm capitalize'}>
|
||||
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
|
||||
|
||||
export function RemoveMemberDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
teamAccountId,
|
||||
userId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
teamAccountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey="teamS:removeMemberModalHeading" />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans i18nKey={'teams:removeMemberModalDescription'} />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<RemoveMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
accountId={teamAccountId}
|
||||
userId={userId}
|
||||
/>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveMemberForm({
|
||||
accountId,
|
||||
userId,
|
||||
setIsOpen,
|
||||
}: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onMemberRemoved = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await removeMemberFromAccountAction({ accountId, userId });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={onMemberRemoved}>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</p>
|
||||
|
||||
<If condition={error}>
|
||||
<RemoveMemberErrorAlert />
|
||||
</If>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-remove-member'}
|
||||
variant={'destructive'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trans i18nKey={'teams:removeMemberSubmitLabel'} />
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveMemberErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'teams:removeMemberErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'teams:removeMemberErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Role = string;
|
||||
|
||||
const roles = {
|
||||
owner: '',
|
||||
member:
|
||||
'bg-blue-50 hover:bg-blue-50 text-blue-500 dark:bg-blue-500/10 dark:hover:bg-blue-500/10',
|
||||
};
|
||||
|
||||
const roleClassNameBuilder = cva('font-medium capitalize shadow-none', {
|
||||
variants: {
|
||||
role: roles,
|
||||
},
|
||||
});
|
||||
|
||||
export function RoleBadge({ role }: { role: Role }) {
|
||||
// @ts-expect-error: hard to type this since users can add custom roles
|
||||
const className = roleClassNameBuilder({ role });
|
||||
const isCustom = !(role in roles);
|
||||
|
||||
return (
|
||||
<Badge className={className} variant={isCustom ? 'outline' : 'default'}>
|
||||
<span data-test={'member-role-badge'}>
|
||||
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
|
||||
export function RolesDataProvider(props: {
|
||||
maxRoleHierarchy: number;
|
||||
children: (roles: string[]) => React.ReactNode;
|
||||
}) {
|
||||
const rolesQuery = useFetchRoles(props);
|
||||
|
||||
if (rolesQuery.isLoading) {
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
if (rolesQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{props.children(rolesQuery.data ?? [])}</>;
|
||||
}
|
||||
|
||||
function useFetchRoles(props: { maxRoleHierarchy: number }) {
|
||||
const supabase = useSupabase();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['roles', props.maxRoleHierarchy],
|
||||
queryFn: async () => {
|
||||
const { error, data } = await supabase
|
||||
.from('roles')
|
||||
.select('name')
|
||||
.gte('hierarchy_level', props.maxRoleHierarchy)
|
||||
.order('hierarchy_level', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.map((item) => item.name);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Form } from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
|
||||
|
||||
export function TransferOwnershipDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
targetDisplayName,
|
||||
accountId,
|
||||
userId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
targetDisplayName: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey="team:transferOwnership" />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans i18nKey="team:transferOwnershipDescription" />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<TransferOrganizationOwnershipForm
|
||||
accountId={accountId}
|
||||
userId={userId}
|
||||
targetDisplayName={targetDisplayName}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOrganizationOwnershipForm({
|
||||
accountId,
|
||||
userId,
|
||||
targetDisplayName,
|
||||
setIsOpen,
|
||||
}: {
|
||||
userId: string;
|
||||
accountId: string;
|
||||
targetDisplayName: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const form = useForm<{
|
||||
accountId: string;
|
||||
userId: string;
|
||||
otp: string;
|
||||
}>({
|
||||
resolver: zodResolver(TransferOwnershipConfirmationSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
userId,
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { otp } = useWatch({ control: form.control });
|
||||
|
||||
// If no OTP has been entered yet, show the OTP verification form
|
||||
if (!otp) {
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
<VerifyOtpForm
|
||||
purpose={`transfer-team-ownership-${accountId}`}
|
||||
email={user?.email || ''}
|
||||
onSuccess={(otpValue) => {
|
||||
form.setValue('otp', otpValue, { shouldValidate: true });
|
||||
}}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
data-test="verify-otp-form"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4 text-sm'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await transferOwnershipAction(data);
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<If condition={error}>
|
||||
<TransferOwnershipErrorAlert />
|
||||
</If>
|
||||
|
||||
<div className="border-destructive rounded-md border p-4">
|
||||
<p className="text-destructive text-sm">
|
||||
<Trans
|
||||
i18nKey={'teams:transferOwnershipDisclaimer'}
|
||||
values={{
|
||||
member: targetDisplayName,
|
||||
}}
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="otp" value={otp} />
|
||||
|
||||
<div>
|
||||
<p className={'text-muted-foreground'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
type={'submit'}
|
||||
data-test={'confirm-transfer-ownership-button'}
|
||||
variant={'destructive'}
|
||||
disabled={pending}
|
||||
>
|
||||
<If
|
||||
condition={pending}
|
||||
fallback={<Trans i18nKey={'teams:transferOwnership'} />}
|
||||
>
|
||||
<Trans i18nKey={'teams:transferringOwnership'} />
|
||||
</If>
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOwnershipErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'teams:transferTeamErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'teams:transferTeamErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { RoleSchema } from '../../schema/update-member-role.schema';
|
||||
import { updateMemberRoleAction } from '../../server/actions/team-members-server-actions';
|
||||
import { MembershipRoleSelector } from './membership-role-selector';
|
||||
import { RolesDataProvider } from './roles-data-provider';
|
||||
|
||||
type Role = string;
|
||||
|
||||
export function UpdateMemberRoleDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
userId,
|
||||
teamAccountId,
|
||||
userRole,
|
||||
userRoleHierarchy,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
userId: string;
|
||||
teamAccountId: string;
|
||||
userRole: Role;
|
||||
userRoleHierarchy: number;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
{(data) => (
|
||||
<UpdateMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
userId={userId}
|
||||
teamAccountId={teamAccountId}
|
||||
userRole={userRole}
|
||||
roles={data}
|
||||
/>
|
||||
)}
|
||||
</RolesDataProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateMemberForm({
|
||||
userId,
|
||||
userRole,
|
||||
teamAccountId,
|
||||
setIsOpen,
|
||||
roles,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
userRole: Role;
|
||||
teamAccountId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
roles: Role[];
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
const onSubmit = ({ role }: { role: Role }) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateMemberRoleAction({
|
||||
accountId: teamAccountId,
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
RoleSchema.refine(
|
||||
(data) => {
|
||||
return data.role !== userRole;
|
||||
},
|
||||
{
|
||||
message: t(`roleMustBeDifferent`),
|
||||
path: ['role'],
|
||||
},
|
||||
),
|
||||
),
|
||||
reValidateMode: 'onChange',
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
role: userRole,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-member-role-form'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={'flex flex-col space-y-6'}
|
||||
>
|
||||
<If condition={error}>
|
||||
<UpdateRoleErrorAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'role'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t('roleLabel')}</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
roles={roles}
|
||||
currentUserRole={userRole}
|
||||
value={field.value}
|
||||
onChange={(newRole) => form.setValue('role', newRole)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>{t('updateRoleDescription')}</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button data-test={'confirm-update-member-role'} disabled={pending}>
|
||||
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateRoleErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'teams:updateRoleErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'teams:updateRoleErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user