Cleanup
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { InviteMembersSchema } from '../schema/invite-members.schema';
|
||||
import { AccountInvitationsService } from '../services/account-invitations.service';
|
||||
|
||||
/**
|
||||
* Creates invitations for inviting members.
|
||||
*/
|
||||
export async function createInvitationsAction(params: {
|
||||
account: string;
|
||||
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const { invitations } = InviteMembersSchema.parse({
|
||||
invitations: params.invitations,
|
||||
});
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.sendInvitations({ invitations, account: params.account });
|
||||
|
||||
revalidatePath('/home/[account]/members', 'page');
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an invitation specified by the invitation ID.
|
||||
*
|
||||
* @param {Object} params - The parameters for the method.
|
||||
* @param {string} params.invitationId - The ID of the invitation to be deleted.
|
||||
*
|
||||
* @return {Object} - The result of the delete operation.
|
||||
*/
|
||||
export async function deleteInvitationAction(params: { invitationId: string }) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.removeInvitation({
|
||||
invitationId: params.invitationId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateInvitationAction(params: {
|
||||
invitationId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.updateInvitation({
|
||||
invitationId: params.invitationId,
|
||||
role: params.role,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function assertSession(client: SupabaseClient<Database>) {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use server';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { AccountMembersService } from '../services/account-members.service';
|
||||
|
||||
export async function removeMemberFromAccountAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
|
||||
const service = new AccountMembersService(client);
|
||||
|
||||
await service.removeMemberFromAccount({
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateMemberRoleAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const service = new AccountMembersService(client);
|
||||
|
||||
await service.updateMemberRole({
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
role: params.role,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function transferOwnershipAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const service = new AccountMembersService(client);
|
||||
|
||||
await service.transferOwnership({
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function assertSession(client: SupabaseClient<Database>) {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema';
|
||||
import { DeleteAccountService } from '../services/delete-account.service';
|
||||
|
||||
export async function deleteTeamAccountAction(formData: FormData) {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = DeleteTeamAccountSchema.parse(body);
|
||||
const client = getSupabaseServerActionClient();
|
||||
const service = new DeleteAccountService(client);
|
||||
|
||||
await service.deleteTeamAccount(params);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { LeaveTeamAccountSchema } from '../schema/leave-team-account.schema';
|
||||
import { LeaveAccountService } from '../services/leave-account.service';
|
||||
|
||||
export async function leaveTeamAccountAction(formData: FormData) {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = LeaveTeamAccountSchema.parse(body);
|
||||
const service = new LeaveAccountService(getSupabaseServerActionClient());
|
||||
|
||||
await service.leaveTeamAccount(params);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
5
packages/features/team-accounts/src/components/index.ts
Normal file
5
packages/features/team-accounts/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './members/account-members-table';
|
||||
export * from './update-organization-form';
|
||||
export * from './members/invite-members-dialog-container';
|
||||
export * from './team-account-danger-zone';
|
||||
export * from './invitations/account-invitations-table';
|
||||
@@ -0,0 +1,144 @@
|
||||
'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 { DeleteInvitationDialog } from './delete-invitation-dialog';
|
||||
import { UpdateInvitationDialog } from './update-invitation-dialog';
|
||||
|
||||
type Invitations =
|
||||
Database['public']['Functions']['get_account_invitations']['Returns'];
|
||||
|
||||
type AccountInvitationsTableProps = {
|
||||
invitations: Invitations;
|
||||
|
||||
permissions: {
|
||||
canUpdateInvitation: boolean;
|
||||
canRemoveInvitation: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function AccountInvitationsTable({
|
||||
invitations,
|
||||
permissions,
|
||||
}: AccountInvitationsTableProps) {
|
||||
const columns = useMemo(() => getColumns(permissions), [permissions]);
|
||||
|
||||
return <DataTable columns={columns} data={invitations} />;
|
||||
}
|
||||
|
||||
function getColumns(permissions: {
|
||||
canUpdateInvitation: boolean;
|
||||
canRemoveInvitation: boolean;
|
||||
}): ColumnDef<Invitations[0]>[] {
|
||||
return [
|
||||
{
|
||||
header: 'Email',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const member = row.original;
|
||||
const email = member.email;
|
||||
|
||||
return (
|
||||
<span className={'flex items-center space-x-4 text-left'}>
|
||||
<span>
|
||||
<ProfileAvatar text={email} />
|
||||
</span>
|
||||
|
||||
<span>{email}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
cell: ({ row }) => {
|
||||
const { role } = row.original;
|
||||
|
||||
return <RoleBadge role={role} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Invited At',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.created_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<ActionsDropdown permissions={permissions} invitation={row.original} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ActionsDropdown({
|
||||
permissions,
|
||||
invitation,
|
||||
}: {
|
||||
permissions: AccountInvitationsTableProps['permissions'];
|
||||
invitation: Invitations[0];
|
||||
}) {
|
||||
const [isDeletingInvite, setIsDeletingInvite] = useState(false);
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'} size={'icon'}>
|
||||
<EllipsisIcon className={'h-5 w-5'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={permissions.canUpdateInvitation}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
Update Invitation
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canRemoveInvitation}>
|
||||
<DropdownMenuItem onClick={() => setIsDeletingInvite(true)}>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<If condition={isDeletingInvite}>
|
||||
<DeleteInvitationDialog
|
||||
isOpen
|
||||
setIsOpen={setIsDeletingInvite}
|
||||
invitationId={invitation.id}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isUpdatingRole}>
|
||||
<UpdateInvitationDialog
|
||||
isOpen
|
||||
setIsOpen={setIsUpdatingRole}
|
||||
invitationId={invitation.id}
|
||||
userRole={invitation.role}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 { deleteInvitationAction } from '../../actions/account-invitations-server-actions';
|
||||
|
||||
export const DeleteInvitationDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
invitationId: string;
|
||||
}> = ({ isOpen, setIsOpen, invitationId }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="organization:deleteInvitationDialogTitle" />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Remove the invitation to join this account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DeleteInvitationForm
|
||||
setIsOpen={setIsOpen}
|
||||
invitationId={invitationId}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function DeleteInvitationForm({
|
||||
invitationId,
|
||||
setIsOpen,
|
||||
}: {
|
||||
invitationId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onInvitationRemoved = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteInvitationAction({ invitationId });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={onInvitationRemoved}>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<p className={'text-sm'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</p>
|
||||
|
||||
<If condition={error}>
|
||||
<RemoveInvitationErrorAlert />
|
||||
</If>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-delete-invitation'}
|
||||
variant={'destructive'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Delete Invitation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveInvitationErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:deleteInvitationErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:deleteInvitationErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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 { updateInvitationAction } from '../../actions/account-invitations-server-actions';
|
||||
import { UpdateRoleSchema } from '../../schema/update-role-schema';
|
||||
import { MembershipRoleSelector } from '../membership-role-selector';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const UpdateInvitationDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
invitationId: string;
|
||||
userRole: Role;
|
||||
}> = ({ isOpen, setIsOpen, invitationId, userRole }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'organization:updateMemberRoleModalHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'organization:updateMemberRoleModalDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<UpdateInvitationForm
|
||||
setIsOpen={setIsOpen}
|
||||
invitationId={invitationId}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function UpdateInvitationForm({
|
||||
invitationId,
|
||||
userRole,
|
||||
setIsOpen,
|
||||
}: React.PropsWithChildren<{
|
||||
invitationId: string;
|
||||
userRole: Role;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onSubmit = ({ role }: { role: Role }) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateInvitationAction({ invitationId, 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const MembershipRoleSelector: React.FC<{
|
||||
value: Role;
|
||||
currentUserRole?: Role;
|
||||
onChange: (role: Role) => unknown;
|
||||
}> = ({ value, currentUserRole, onChange }) => {
|
||||
const rolesList: Role[] = ['owner', 'member'];
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger data-test={'role-selector-trigger'}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{rolesList.map((role) => {
|
||||
return (
|
||||
<SelectItem
|
||||
key={role}
|
||||
data-test={`role-item-${role}`}
|
||||
disabled={currentUserRole && currentUserRole === role}
|
||||
value={role}
|
||||
>
|
||||
<span className={'text-sm capitalize'}>
|
||||
<Trans i18nKey={`common.roles.${role}`} defaults={role} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
const roleClassNameBuilder = cva('font-medium capitalize', {
|
||||
variants: {
|
||||
role: {
|
||||
owner: 'bg-primary',
|
||||
member: 'bg-blue-50 text-blue-500 hover:bg-blue-50',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const RoleBadge: React.FC<{
|
||||
role: Role;
|
||||
}> = ({ role }) => {
|
||||
const className = roleClassNameBuilder({ role });
|
||||
|
||||
return (
|
||||
<Badge className={className}>
|
||||
<span data-test={'member-role-badge'}>
|
||||
<Trans i18nKey={`common.roles.${role}`} defaults={role} />
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { ErrorBoundary } from '@kit/ui/error-boundary';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { deleteTeamAccountAction } from '../actions/delete-team-account-server-actions';
|
||||
import { leaveTeamAccountAction } from '../actions/leave-team-account-server-actions';
|
||||
|
||||
type AccountData =
|
||||
Database['public']['Functions']['organization_account_workspace']['Returns'][0];
|
||||
|
||||
export function TeamAccountDangerZone({
|
||||
account,
|
||||
userId,
|
||||
}: React.PropsWithChildren<{
|
||||
account: AccountData;
|
||||
userId: string;
|
||||
}>) {
|
||||
const isPrimaryOwner = userId === account.primary_owner_user_id;
|
||||
|
||||
if (isPrimaryOwner) {
|
||||
return <DeleteOrganizationContainer account={account} />;
|
||||
}
|
||||
|
||||
return <LeaveOrganizationContainer account={account} />;
|
||||
}
|
||||
|
||||
function DeleteOrganizationContainer(props: { account: AccountData }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<Heading level={6}>
|
||||
<Trans i18nKey={'organization:deleteOrganization'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-sm text-gray-500'}>
|
||||
<Trans
|
||||
i18nKey={'organization:deleteOrganizationDescription'}
|
||||
values={{
|
||||
organizationName: props.account.name,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
data-test={'delete-organization-button'}
|
||||
type={'button'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
<Trans i18nKey={'organization:deleteOrganization'} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'organization:deletingOrganization'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DeleteOrganizationForm
|
||||
name={props.account.name}
|
||||
id={props.account.id}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteOrganizationForm({ name, id }: { name: string; id: string }) {
|
||||
return (
|
||||
<ErrorBoundary fallback={<DeleteOrganizationErrorAlert />}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
action={deleteTeamAccountAction}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div
|
||||
className={
|
||||
'border-2 border-red-500 p-4 text-sm text-red-500' +
|
||||
' flex flex-col space-y-2'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey={'organization:deleteOrganizationDisclaimer'}
|
||||
values={{
|
||||
organizationName: name,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'text-sm'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" value={id} name={'id'} />
|
||||
|
||||
<Label>
|
||||
<Trans i18nKey={'organization:organizationNameInputLabel'} />
|
||||
|
||||
<Input
|
||||
name={'name'}
|
||||
data-test={'delete-organization-input-field'}
|
||||
required
|
||||
type={'text'}
|
||||
className={'w-full'}
|
||||
placeholder={''}
|
||||
pattern={name}
|
||||
/>
|
||||
|
||||
<span className={'text-xs'}>
|
||||
<Trans i18nKey={'organization:deleteOrganizationInputField'} />
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<DeleteOrganizationSubmitButton />
|
||||
</div>
|
||||
</form>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteOrganizationSubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-organization-button'}
|
||||
disabled={pending}
|
||||
variant={'destructive'}
|
||||
>
|
||||
<Trans i18nKey={'organization:deleteOrganization'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaveOrganizationContainer(props: { account: AccountData }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'organization:leaveOrganizationDescription'}
|
||||
values={{
|
||||
organizationName: props.account.name,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
data-test={'leave-organization-button'}
|
||||
type={'button'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
<Trans i18nKey={'organization:leaveOrganization'} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans
|
||||
i18nKey={'organization:leavingOrganizationModalHeading'}
|
||||
/>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ErrorBoundary fallback={<LeaveOrganizationErrorAlert />}>
|
||||
<form action={leaveTeamAccountAction}>
|
||||
<input type={'hidden'} value={props.account.id} name={'id'} />
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey={'organization:leaveOrganizationDisclaimer'}
|
||||
values={{
|
||||
organizationName: props.account?.name,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<LeaveOrganizationSubmitButton />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ErrorBoundary>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaveOrganizationSubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-leave-organization-button'}
|
||||
disabled={pending}
|
||||
variant={'destructive'}
|
||||
>
|
||||
<Trans i18nKey={'organization:leaveOrganization'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaveOrganizationErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:leaveOrganizationErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteOrganizationErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:deleteOrganizationErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useUpdateAccountData } from '@kit/accounts/hooks/use-update-account';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
export const UpdateOrganizationForm = (props: {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
}) => {
|
||||
const updateAccountData = useUpdateAccountData(props.accountId);
|
||||
const { t } = useTranslation('organization');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
name: props.accountName,
|
||||
},
|
||||
});
|
||||
|
||||
const updateOrganizationData = useCallback(
|
||||
(data: { name: string }) => {
|
||||
const promise = updateAccountData.mutateAsync(data);
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t(`updateOrganizationLoadingMessage`),
|
||||
success: t(`updateOrganizationSuccessMessage`),
|
||||
error: t(`updateOrganizationErrorMessage`),
|
||||
});
|
||||
},
|
||||
[t, updateAccountData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'space-y-8'}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
updateOrganizationData(data);
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans
|
||||
i18nKey={'organization:organizationNameInputLabel'}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'organization-name-input'}
|
||||
required
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
></FormField>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
className={'w-full md:w-auto'}
|
||||
data-test={'update-organization-submit-button'}
|
||||
disabled={updateAccountData.isPending}
|
||||
>
|
||||
<Trans i18nKey={'organization:updateOrganizationSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DeleteTeamAccountSchema = z.object({
|
||||
accountId: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.custom<Role>(() => z.string().min(1)),
|
||||
});
|
||||
|
||||
export const InviteMembersSchema = z
|
||||
.object({
|
||||
invitations: InviteSchema.array(),
|
||||
})
|
||||
.refine((data) => {
|
||||
if (!data.invitations.length) {
|
||||
return {
|
||||
message: 'At least one invite is required',
|
||||
path: ['invites'],
|
||||
};
|
||||
}
|
||||
|
||||
const emails = data.invitations.map((member) => member.email.toLowerCase());
|
||||
const uniqueEmails = new Set(emails);
|
||||
|
||||
if (emails.length !== uniqueEmails.size) {
|
||||
return {
|
||||
message: 'Duplicate emails are not allowed',
|
||||
path: ['invites'],
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LeaveTeamAccountSchema = z.object({
|
||||
accountId: z.string(),
|
||||
userId: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const confirmationString = 'TRANSFER';
|
||||
|
||||
export const TransferOwnershipConfirmationSchema = z
|
||||
.object({
|
||||
confirmation: z.string(),
|
||||
})
|
||||
.refine((data) => data.confirmation === confirmationString, {
|
||||
message: `Confirmation must be ${confirmationString}`,
|
||||
path: ['confirmation'],
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const UpdateRoleSchema = z.object({
|
||||
role: z.custom<Role>((value) => z.string().parse(value)),
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import 'server-only';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Mailer } from '@kit/mailers';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { InviteMembersSchema } from '../schema/invite-members.schema';
|
||||
|
||||
const invitePath = process.env.INVITATION_PAGE_PATH;
|
||||
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
|
||||
const emailSender = process.env.EMAIL_SENDER;
|
||||
|
||||
const env = z
|
||||
.object({
|
||||
invitePath: z.string().min(1),
|
||||
siteURL: z.string().min(1),
|
||||
productName: z.string(),
|
||||
emailSender: z.string().email(),
|
||||
})
|
||||
.parse({
|
||||
invitePath,
|
||||
siteURL,
|
||||
productName,
|
||||
emailSender,
|
||||
});
|
||||
|
||||
export class AccountInvitationsService {
|
||||
private namespace = 'accounts.invitations';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async removeInvitation(params: { invitationId: string }) {
|
||||
Logger.info('Removing invitation', {
|
||||
invitationId: params.invitationId,
|
||||
name: this.namespace,
|
||||
});
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('invitations')
|
||||
.delete()
|
||||
.match({
|
||||
id: params.invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info('Invitation successfully removed', {
|
||||
invitationId: params.invitationId,
|
||||
name: this.namespace,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateInvitation(params: {
|
||||
invitationId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
}) {
|
||||
Logger.info('Updating invitation', {
|
||||
invitationId: params.invitationId,
|
||||
role: params.role,
|
||||
name: this.namespace,
|
||||
});
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('invitations')
|
||||
.update({
|
||||
role: params.role,
|
||||
})
|
||||
.match({
|
||||
id: params.invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info('Invitation successfully updated', {
|
||||
invitationId: params.invitationId,
|
||||
role: params.role,
|
||||
name: this.namespace,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async sendInvitations({
|
||||
account,
|
||||
invitations,
|
||||
}: {
|
||||
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
|
||||
account: string;
|
||||
}) {
|
||||
Logger.info(
|
||||
{ account, invitations, name: this.namespace },
|
||||
'Storing invitations',
|
||||
);
|
||||
|
||||
const mailer = new Mailer();
|
||||
|
||||
const { user } = await this.getUser();
|
||||
|
||||
const accountResponse = await this.client
|
||||
.from('accounts')
|
||||
.select('name')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!accountResponse.data) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const response = await this.client.rpc('add_invitations_to_account', {
|
||||
invitations,
|
||||
account_slug: account,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
|
||||
const responseInvitations = Array.isArray(response.data)
|
||||
? response.data
|
||||
: [response.data];
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
account,
|
||||
count: responseInvitations.length,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Invitations added to account',
|
||||
);
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
account,
|
||||
count: responseInvitations.length,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Sending invitation emails...',
|
||||
);
|
||||
|
||||
for (const invitation of responseInvitations) {
|
||||
const promise = async () => {
|
||||
try {
|
||||
const { renderInviteEmail } = await import('@kit/emails');
|
||||
|
||||
const html = await renderInviteEmail({
|
||||
link: this.getInvitationLink(invitation.invite_token),
|
||||
invitedUserEmail: invitation.email,
|
||||
inviter: user.email,
|
||||
productName: env.productName,
|
||||
organizationName: accountResponse.data.name,
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
from: env.emailSender,
|
||||
to: invitation.email,
|
||||
subject: 'You have been invited to join a team',
|
||||
html,
|
||||
});
|
||||
|
||||
Logger.info('Invitation email sent', {
|
||||
email: invitation.email,
|
||||
account,
|
||||
name: this.namespace,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.warn(
|
||||
{ account, error, name: this.namespace },
|
||||
'Failed to send invitation email',
|
||||
);
|
||||
|
||||
return {
|
||||
error,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(promises.map((promise) => promise()));
|
||||
const success = responses.filter((response) => response.success).length;
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
account,
|
||||
success,
|
||||
failed: responses.length - success,
|
||||
},
|
||||
`Invitations processed`,
|
||||
);
|
||||
}
|
||||
|
||||
private async getUser() {
|
||||
const { data, error } = await this.client.auth.getUser();
|
||||
|
||||
if (error ?? !data) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private getInvitationLink(token: string) {
|
||||
return new URL(env.invitePath, env.siteURL).href + `?token=${token}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import 'server-only';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class AccountMembersService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async removeMemberFromAccount(params: { accountId: string; userId: string }) {
|
||||
const { data, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.delete()
|
||||
.match({
|
||||
id: params.accountId,
|
||||
user_id: params.userId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateMemberRole(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
}) {
|
||||
const { data, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.update({
|
||||
account_role: params.role,
|
||||
})
|
||||
.match({
|
||||
account_id: params.accountId,
|
||||
user_id: params.userId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async transferOwnership(params: { accountId: string; userId: string }) {
|
||||
const { data, error } = await this.client
|
||||
.from('accounts')
|
||||
.update({
|
||||
primary_owner_user_id: params.userId,
|
||||
})
|
||||
.match({
|
||||
id: params.accountId,
|
||||
user_id: params.userId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import 'server-only';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class DeleteAccountService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async deleteTeamAccount(params: { accountId: string }) {
|
||||
// TODO
|
||||
// implement this method
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import 'server-only';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class LeaveAccountService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async leaveTeamAccount(params: { accountId: string; userId: string }) {
|
||||
// TODO
|
||||
// implement this method
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user