This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -0,0 +1,224 @@
'use client';
import { useMemo, useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { Database } from '@kit/supabase/database';
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 { ProfileAvatar } from '@kit/ui/profile-avatar';
import { RoleBadge } from '../role-badge';
import { RemoveMemberDialog } from './remove-member-dialog';
import { TransferOwnershipDialog } from './transfer-ownership-dialog';
import { UpdateMemberRoleDialog } from './update-member-role-dialog';
type Members =
Database['public']['Functions']['get_account_members']['Returns'];
type AccountMembersTableProps = {
members: Members;
currentUserId: string;
permissions: {
canUpdateRole: boolean;
canTransferOwnership: boolean;
canRemoveFromAccount: boolean;
};
};
export function AccountMembersTable({
members,
permissions,
currentUserId,
}: AccountMembersTableProps) {
const columns = useMemo(
() => getColumns(permissions, currentUserId),
[currentUserId, permissions],
);
return <DataTable columns={columns} data={members} />;
}
function getColumns(
permissions: {
canUpdateRole: boolean;
canTransferOwnership: boolean;
canRemoveFromAccount: boolean;
},
currentUserId: string,
): ColumnDef<Members[0]>[] {
return [
{
header: 'Name',
size: 200,
cell: ({ row }) => {
const member = row.original;
const displayName = member.name ?? member.email.split('@')[0];
const isSelf = member.user_id === 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}>
<span
className={
'bg-muted rounded-md px-2.5 py-1 text-xs font-medium'
}
>
You
</span>
</If>
</span>
);
},
},
{
header: 'Email',
accessorKey: 'email',
cell: ({ row }) => {
return row.original.email ?? '-';
},
},
{
header: 'Role',
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'
}
>
Primary
</span>
</If>
</span>
);
},
},
{
header: 'Joined At',
cell: ({ row }) => {
return new Date(row.original.created_at).toLocaleDateString();
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => (
<ActionsDropdown
permissions={permissions}
member={row.original}
currentUserId={currentUserId}
/>
),
},
];
}
function ActionsDropdown({
permissions,
member,
currentUserId,
}: {
permissions: AccountMembersTableProps['permissions'];
member: Members[0];
currentUserId: string;
}) {
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;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<EllipsisIcon className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<If condition={permissions.canUpdateRole}>
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
Update Role
</DropdownMenuItem>
</If>
<If condition={permissions.canTransferOwnership}>
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
Transfer Ownership
</DropdownMenuItem>
</If>
<If condition={permissions.canRemoveFromAccount}>
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
Remove from Account
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
<If condition={isRemoving}>
<RemoveMemberDialog
isOpen
setIsOpen={setIsRemoving}
accountId={member.id}
userId={member.user_id}
/>
</If>
<If condition={isUpdatingRole}>
<UpdateMemberRoleDialog
isOpen
setIsOpen={setIsUpdatingRole}
accountId={member.id}
userId={member.user_id}
userRole={member.role}
/>
</If>
<If condition={isTransferring}>
<TransferOwnershipDialog
isOpen
setIsOpen={setIsTransferring}
targetDisplayName={member.name ?? member.email}
accountId={member.id}
userId={member.user_id}
/>
</If>
</>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { PlusIcon, XIcon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import { createInvitationsAction } from '../../actions/account-invitations-server-actions';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { MembershipRoleSelector } from '../membership-role-selector';
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = Database['public']['Enums']['account_role'];
export function InviteMembersDialogContainer({
account,
children,
}: React.PropsWithChildren<{
account: string;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Invite Members to Organization</DialogTitle>
<DialogDescription>
Invite members to your organization by entering their email and
role.
</DialogDescription>
</DialogHeader>
<InviteMembersForm
pending={pending}
onSubmit={(data) => {
startTransition(async () => {
await createInvitationsAction({
account,
invitations: data.invitations,
});
setIsOpen(false);
});
}}
/>
</DialogContent>
</Dialog>
);
}
function InviteMembersForm({
onSubmit,
pending,
}: {
onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean;
}) {
const { t } = useTranslation('organization');
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 space-y-4">
{fieldArray.fields.map((field, index) => {
const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const;
return (
<div key={field.id}>
<div className={'flex items-end space-x-0.5 md:space-x-2'}>
<div className={'w-7/12'}>
<FormField
name={emailInputName}
render={({ field }) => {
return (
<FormItem>
<FormLabel>{t('emailLabel')}</FormLabel>
<FormControl>
<Input
data-test={'invite-email-input'}
placeholder="member@email.com"
type="email"
required
{...field}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<div className={'w-4/12'}>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<MembershipRoleSelector
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<div className={'flex w-[60px] justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'outline'}
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);
}}
>
<XIcon className={'h-4 lg:h-5'} />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('removeInviteButtonLabel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
})}
<div>
<Button
data-test={'append-new-invite-button'}
type={'button'}
variant={'outline'}
size={'sm'}
disabled={pending}
onClick={() => {
fieldArray.append(createEmptyInviteModel());
}}
>
<span className={'flex items-center space-x-2'}>
<PlusIcon className={'h-4'} />
<span>
<Trans i18nKey={'organization:addAnotherMemberButtonLabel'} />
</span>
</span>
</Button>
</div>
</div>
<Button disabled={pending}>
{pending ? 'Inviting...' : 'Invite Members'}
</Button>
</form>
</Form>
);
}
function createEmptyInviteModel() {
return { email: '', role: 'member' as Role };
}

View File

@@ -0,0 +1,106 @@
import { useState, useTransition } from 'react';
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 { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../actions/account-members-server-actions';
export const RemoveMemberDialog: React.FC<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
accountId: string;
userId: string;
}> = ({ isOpen, setIsOpen, accountId, userId }) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="organization:removeMemberModalHeading" />
</DialogTitle>
<DialogDescription>
Remove this member from the organization.
</DialogDescription>
</DialogHeader>
<RemoveMemberForm
setIsOpen={setIsOpen}
accountId={accountId}
userId={userId}
/>
</DialogContent>
</Dialog>
);
};
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 (e) {
setError(true);
}
});
};
return (
<form action={onMemberRemoved}>
<div className={'flex flex-col space-y-6'}>
<p className={'text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<If condition={error}>
<RemoveMemberErrorAlert />
</If>
<Button
data-test={'confirm-remove-member'}
variant={'destructive'}
disabled={isSubmitting}
onClick={onMemberRemoved}
>
<Trans i18nKey={'organization:removeMemberSubmitLabel'} />
</Button>
</div>
</form>
);
}
function RemoveMemberErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'organization:removeMemberErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'organization:removeMemberErrorMessage'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
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 { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { transferOwnershipAction } from '../../actions/account-members-server-actions';
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
export const TransferOwnershipDialog: React.FC<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
accountId: string;
userId: string;
targetDisplayName: string;
}> = ({ isOpen, setIsOpen, targetDisplayName, accountId, userId }) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="organization:transferOwnership" />
</DialogTitle>
<DialogDescription>
Transfer ownership of the organization to another member.
</DialogDescription>
</DialogHeader>
<TransferOrganizationOwnershipForm
accountId={accountId}
userId={userId}
targetDisplayName={targetDisplayName}
setIsOpen={setIsOpen}
/>
</DialogContent>
</Dialog>
);
};
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 onSubmit = () => {
startTransition(async () => {
try {
await transferOwnershipAction({
accountId,
userId,
});
setIsOpen(false);
} catch (error) {
setError(true);
}
});
};
const form = useForm({
resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: {
confirmation: '',
},
});
return (
<Form {...form}>
<form
className={'flex flex-col space-y-2 text-sm'}
onSubmit={form.handleSubmit(onSubmit)}
>
<If condition={error}>
<TransferOwnershipErrorAlert />
</If>
<p>
<Trans
i18nKey={'organization:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
components={{ b: <b /> }}
/>
</p>
<p>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<FormField
name={'confirmation'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
Please type TRANSFER to confirm the transfer of ownership.
</FormLabel>
<FormControl>
<Input type={'text'} required {...field} />
</FormControl>
<FormDescription>
Please make sure you understand the implications of this
action.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<Button
type={'submit'}
data-test={'confirm-transfer-ownership-button'}
variant={'destructive'}
disabled={pending}
>
<If
condition={pending}
fallback={<Trans i18nKey={'organization:transferOwnership'} />}
>
<Trans i18nKey={'organization:transferringOwnership'} />
</If>
</Button>
</form>
</Form>
);
}
function TransferOwnershipErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'organization:transferOrganizationErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'organization:transferOrganizationErrorMessage'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,162 @@
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Database } from '@kit/supabase/database';
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 { updateMemberRoleAction } from '../../actions/account-members-server-actions';
import { UpdateRoleSchema } from '../../schema/update-role-schema';
import { MembershipRoleSelector } from '../membership-role-selector';
type Role = Database['public']['Enums']['account_role'];
export const UpdateMemberRoleDialog: React.FC<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
userId: string;
accountId: string;
userRole: Role;
}> = ({ isOpen, setIsOpen, userId, accountId, userRole }) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'organization:updateMemberRoleModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'organization:updateMemberRoleModalDescription'} />
</DialogDescription>
</DialogHeader>
<UpdateMemberForm
setIsOpen={setIsOpen}
userId={userId}
accountId={accountId}
userRole={userRole}
/>
</DialogContent>
</Dialog>
);
};
function UpdateMemberForm({
userId,
userRole,
accountId,
setIsOpen,
}: React.PropsWithChildren<{
userId: string;
userRole: Role;
accountId: string;
setIsOpen: (isOpen: boolean) => void;
}>) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateMemberRoleAction({ accountId, userId, role });
setIsOpen(false);
} catch (e) {
setError(true);
}
});
};
const form = useForm({
resolver: zodResolver(
UpdateRoleSchema.refine(
(data) => {
return data.role !== userRole;
},
{
message: 'Role must be different from the current role.',
path: ['role'],
},
),
),
reValidateMode: 'onChange',
mode: 'onChange',
defaultValues: {
role: userRole,
},
});
return (
<Form {...form}>
<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>New Role</FormLabel>
<FormControl>
<MembershipRoleSelector
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => form.setValue('role', newRole)}
/>
</FormControl>
<FormDescription>Pick a role for this member.</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<Button data-test={'confirm-update-member-role'} disabled={pending}>
<Trans i18nKey={'organization:updateRoleSubmitLabel'} />
</Button>
</form>
</Form>
);
}
function UpdateRoleErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'organization:updateRoleErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'organization:updateRoleErrorMessage'} />
</AlertDescription>
</Alert>
);
}