Cleanup
This commit is contained in:
49
packages/features/accounts/package.json
Normal file
49
packages/features/accounts/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@kit/accounts",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./personal-account-dropdown": "./src/components/personal-account-dropdown.tsx",
|
||||
"./account-selector": "./src/components/account-selector.tsx",
|
||||
"./personal-account-settings": "./src/components/personal-account-settings/index.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/shared": "0.1.0",
|
||||
"lucide-react": "^0.360.0",
|
||||
"@radix-ui/react-icons": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
218
packages/features/accounts/src/components/account-selector.tsx
Normal file
218
packages/features/accounts/src/components/account-selector.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
|
||||
import { CheckIcon, PlusIcon } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@kit/ui/command';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CreateOrganizationAccountDialog } from './create-organization-account-dialog';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
accounts: Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image?: string | null;
|
||||
}>;
|
||||
|
||||
features: {
|
||||
enableOrganizationAccounts: boolean;
|
||||
enableOrganizationCreation: boolean;
|
||||
};
|
||||
|
||||
selectedAccount?: string;
|
||||
collapsed?: boolean;
|
||||
|
||||
onAccountChange: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
const PERSONAL_ACCOUNT_SLUG = 'personal';
|
||||
|
||||
export function AccountSelector({
|
||||
accounts,
|
||||
selectedAccount,
|
||||
onAccountChange,
|
||||
features = {
|
||||
enableOrganizationAccounts: true,
|
||||
enableOrganizationCreation: true,
|
||||
},
|
||||
collapsed = false,
|
||||
}: React.PropsWithChildren<AccountSelectorProps>) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
|
||||
|
||||
const [value, setValue] = useState<string>(
|
||||
selectedAccount ?? PERSONAL_ACCOUNT_SLUG,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(selectedAccount ?? PERSONAL_ACCOUNT_SLUG);
|
||||
}, [selectedAccount]);
|
||||
|
||||
const Icon = (props: { item: string }) => {
|
||||
return (
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
value === props.item ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const selected = accounts.find((account) => account.value === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={collapsed ? 'icon' : 'default'}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('w-full', {
|
||||
'justify-between': !collapsed,
|
||||
'justify-center': collapsed,
|
||||
})}
|
||||
>
|
||||
<If
|
||||
condition={selected}
|
||||
fallback={
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<PersonIcon className="h-4 w-4" />
|
||||
|
||||
<span
|
||||
className={cn({
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
Personal Account
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(account) => (
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<Avatar className={'h-6 w-6'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback>
|
||||
{account.label ? account.label[0] : ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span
|
||||
className={cn({
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
{account.label}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<CaretSortIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search account..." className="h-9" />
|
||||
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => onAccountChange(undefined)}
|
||||
value={PERSONAL_ACCOUNT_SLUG}
|
||||
>
|
||||
<PersonIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
<span>Personal Account</span>
|
||||
|
||||
<Icon item={PERSONAL_ACCOUNT_SLUG} />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<If condition={features.enableOrganizationAccounts}>
|
||||
<If condition={accounts.length > 0}>
|
||||
<CommandGroup heading={'Your Organizations'}>
|
||||
{(accounts ?? []).map((account) => (
|
||||
<CommandItem
|
||||
key={account.value}
|
||||
value={account.value ?? ''}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? '' : currentValue);
|
||||
setOpen(false);
|
||||
|
||||
if (onAccountChange) {
|
||||
onAccountChange(currentValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar className={'mr-2 h-6 w-6'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback>
|
||||
{account.label ? account.label[0] : ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{account.label}</span>
|
||||
|
||||
<Icon item={account.value ?? ''} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<If condition={features.enableOrganizationCreation}>
|
||||
<CommandGroup>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsCreatingAccount(true);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
<span>Create Organization</span>
|
||||
</Button>
|
||||
</CommandGroup>
|
||||
</If>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<If condition={features.enableOrganizationCreation}>
|
||||
<CreateOrganizationAccountDialog
|
||||
isOpen={isCreatingAccount}
|
||||
setIsOpen={setIsCreatingAccount}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Dialog, DialogContent, 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 { CreateOrganizationAccountSchema } from '../schema/create-organization.schema';
|
||||
import { createOrganizationAccountAction } from '../server/accounts-server-actions';
|
||||
|
||||
export function CreateOrganizationAccountDialog(
|
||||
props: React.PropsWithChildren<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'organization:createOrganizationModalHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<CreateOrganizationAccountForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateOrganizationAccountForm() {
|
||||
const [error, setError] = useState<boolean>();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm<z.infer<typeof CreateOrganizationAccountSchema>>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
resolver: zodResolver(CreateOrganizationAccountSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createOrganizationAccountAction(data);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<If condition={error}>
|
||||
<CreateOrganizationErrorAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'organization:organizationNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'create-organization-name-input'}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Your organization name should be unique and descriptive.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-create-organization-button'}
|
||||
disabled={pending}
|
||||
>
|
||||
<Trans i18nKey={'organization:createOrganizationSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateOrganizationErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:createOrganizationErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:createOrganizationErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Session } from '@supabase/gotrue-js';
|
||||
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
HomeIcon,
|
||||
LogOutIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
ShieldIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { usePersonalAccountData } from '../hooks/use-personal-account-data';
|
||||
|
||||
export function PersonalAccountDropdown({
|
||||
className,
|
||||
session,
|
||||
signOutRequested,
|
||||
showProfileName,
|
||||
paths,
|
||||
}: {
|
||||
className?: string;
|
||||
session: Session | undefined;
|
||||
signOutRequested: () => unknown;
|
||||
showProfileName?: boolean;
|
||||
paths: {
|
||||
home: string;
|
||||
};
|
||||
}) {
|
||||
const { data: personalAccountData } = usePersonalAccountData();
|
||||
const authUser = session?.user;
|
||||
|
||||
const signedInAsLabel = useMemo(() => {
|
||||
const email = authUser?.email ?? undefined;
|
||||
const phone = authUser?.phone ?? undefined;
|
||||
|
||||
return email ?? phone;
|
||||
}, [authUser?.email, authUser?.phone]);
|
||||
|
||||
const displayName = personalAccountData?.name ?? authUser?.email ?? '';
|
||||
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
return authUser?.app_metadata.role === 'super-admin';
|
||||
}, [authUser]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open your profile menu"
|
||||
data-test={'profile-dropdown-trigger'}
|
||||
className={cn(
|
||||
'animate-in fade-in group flex cursor-pointer items-center focus:outline-none',
|
||||
className ?? '',
|
||||
{
|
||||
['items-center space-x-2.5 rounded-lg border' +
|
||||
' hover:bg-muted p-2 transition-colors']: showProfileName,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ProfileAvatar
|
||||
displayName={displayName ?? authUser?.email ?? ''}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
/>
|
||||
|
||||
<If condition={showProfileName}>
|
||||
<div className={'flex w-full flex-col truncate text-left'}>
|
||||
<span className={'truncate text-sm'}>{displayName}</span>
|
||||
|
||||
<span className={'text-muted-foreground truncate text-xs'}>
|
||||
{signedInAsLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EllipsisVerticalIcon
|
||||
className={'text-muted-foreground hidden h-8 group-hover:flex'}
|
||||
/>
|
||||
</If>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={'!min-w-[15rem]'}
|
||||
collisionPadding={{ right: 20, left: 20 }}
|
||||
sideOffset={20}
|
||||
>
|
||||
<DropdownMenuItem className={'!h-10 rounded-none'}>
|
||||
<div
|
||||
className={'flex flex-col justify-start truncate text-left text-xs'}
|
||||
>
|
||||
<div className={'text-gray-500'}>
|
||||
<Trans i18nKey={'common:signedInAs'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex items-center space-x-2'}
|
||||
href={paths.home}
|
||||
>
|
||||
<HomeIcon className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:homeTabLabel'} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className={'s-full flex items-center space-x-2'} href={'/docs'}>
|
||||
<MessageCircleQuestionIcon className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:documentation'} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={isSuperAdmin}>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex items-center space-x-2'}
|
||||
href={'/admin'}
|
||||
>
|
||||
<ShieldIcon className={'h-5'} />
|
||||
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
role={'button'}
|
||||
className={'cursor-pointer'}
|
||||
onClick={signOutRequested}
|
||||
>
|
||||
<span className={'flex w-full items-center space-x-2'}>
|
||||
<LogOutIcon className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'auth:signOut'} />
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
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 { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function AccountDangerZone() {
|
||||
return <DeleteAccountContainer />;
|
||||
}
|
||||
|
||||
function DeleteAccountContainer() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<Heading level={6}>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'profile:deleteAccountDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DeleteAccountModal />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountModal() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-test={'delete-account-button'} variant={'destructive'}>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
|
||||
<DeleteAccountForm />
|
||||
</ErrorBoundary>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountForm() {
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={deleteUserAccountAction}
|
||||
className={'flex flex-col space-y-4'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div
|
||||
className={'border-destructive text-destructive border p-4 text-sm'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div>
|
||||
<Trans i18nKey={'profile:deleteAccountDescription'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:deleteProfileConfirmationInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'delete-account-input-field'}
|
||||
required
|
||||
type={'text'}
|
||||
className={'w-full'}
|
||||
placeholder={''}
|
||||
pattern={`DELETE`}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<DeleteAccountSubmitButton />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountSubmitButton() {
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
name={'action'}
|
||||
value={'delete'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:deleteAccountErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { AccountDangerZone } from './account-danger-zone';
|
||||
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
|
||||
import { UpdateAccountImageContainer } from './update-account-image-container';
|
||||
import { UpdateEmailFormContainer } from './update-email-form-container';
|
||||
import { UpdatePasswordFormContainer } from './update-password-container';
|
||||
|
||||
export function PersonalAccountSettingsContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
features: {
|
||||
enableAccountDeletion: boolean;
|
||||
};
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-8 pb-32'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Profile Picture</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateAccountImageContainer />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Details</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateAccountDetailsFormContainer />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update your Email</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateEmailFormContainer callbackPath={props.paths.callback} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update your Password</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdatePasswordFormContainer callbackPath={props.paths.callback} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={props.features.enableAccountDeletion}>
|
||||
<Card className={'border-destructive border-2'}>
|
||||
<CardHeader>
|
||||
<CardTitle>Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountDangerZone />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account-settings-container';
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
|
||||
import { Alert } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { OtpInput } from '@kit/ui/otp-input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function MultiFactorAuthSetupModal(
|
||||
props: React.PropsWithChildren<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onEnrollSuccess = useCallback(() => {
|
||||
props.setIsOpen(false);
|
||||
|
||||
return toast.success(t(`profile:multiFactorSetupSuccess`));
|
||||
}, [props, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'profile:setupMfaButtonLabel'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<MultiFactorAuthSetupForm
|
||||
onCancel={() => props.setIsOpen(false)}
|
||||
onEnrolled={onEnrollSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiFactorAuthSetupForm({
|
||||
onEnrolled,
|
||||
onCancel,
|
||||
}: React.PropsWithChildren<{
|
||||
onCancel: () => void;
|
||||
onEnrolled: () => void;
|
||||
}>) {
|
||||
const verifyCodeMutation = useVerifyCodeMutation();
|
||||
const [factorId, setFactorId] = useState<string | undefined>();
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
const [state, setState] = useState({
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
setState({
|
||||
loading: true,
|
||||
error: '',
|
||||
});
|
||||
|
||||
if (!factorId || !verificationCode) {
|
||||
return setState({
|
||||
loading: false,
|
||||
error: 'No factor ID or verification code found',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCodeMutation.mutateAsync({
|
||||
factorId,
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
onEnrolled();
|
||||
} catch (error) {
|
||||
const message = (error as Error).message || `Unknown error`;
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}, [onEnrolled, verifyCodeMutation, factorId, verificationCode]);
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<Trans i18nKey={'profile:multiFactorSetupError'} />
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<FactorQrCode onCancel={onCancel} onSetFactorId={setFactorId} />
|
||||
</div>
|
||||
|
||||
<If condition={factorId}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
return onSubmit();
|
||||
}}
|
||||
className={'w-full'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:verificationCode'} />
|
||||
|
||||
<OtpInput
|
||||
onInvalid={() => setVerificationCode('')}
|
||||
onValid={setVerificationCode}
|
||||
/>
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'profile:verifyActivationCodeDescription'} />
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button disabled={!verificationCode} type={'submit'}>
|
||||
{state.loading ? (
|
||||
<Trans i18nKey={'profile:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'profile:enableMfaFactor'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorQrCode({
|
||||
onSetFactorId,
|
||||
onCancel,
|
||||
}: React.PropsWithChildren<{
|
||||
onCancel: () => void;
|
||||
onSetFactorId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}>) {
|
||||
const enrollFactorMutation = useEnrollFactor();
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const [factor, setFactor] = useState({
|
||||
name: '',
|
||||
qrCode: '',
|
||||
});
|
||||
|
||||
const factorName = factor.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (!factorName) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await enrollFactorMutation.mutateAsync(factorName);
|
||||
|
||||
if (!data) {
|
||||
return setError(true);
|
||||
}
|
||||
|
||||
// set image
|
||||
setFactor((factor) => {
|
||||
return {
|
||||
...factor,
|
||||
qrCode: data.totp.qr_code,
|
||||
};
|
||||
});
|
||||
|
||||
// dispatch event to set factor ID
|
||||
onSetFactorId(data.id);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
})();
|
||||
}, [onSetFactorId, factorName, enrollFactorMutation]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-2'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<Trans i18nKey={'profile:qrCodeError'} />
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!factorName) {
|
||||
return (
|
||||
<FactorNameForm
|
||||
onCancel={onCancel}
|
||||
onSetFactorName={(name) => {
|
||||
setFactor((factor) => ({ ...factor, name }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<p>
|
||||
<span className={'text-base'}>
|
||||
<Trans i18nKey={'profile:multiFactorModalHeading'} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<QrImage src={factor.qrCode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorNameForm(
|
||||
props: React.PropsWithChildren<{
|
||||
onSetFactorName: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}>,
|
||||
) {
|
||||
const inputName = 'factorName';
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const data = new FormData(event.currentTarget);
|
||||
const name = data.get(inputName) as string;
|
||||
|
||||
props.onSetFactorName(name);
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:factorNameLabel'} />
|
||||
|
||||
<Input autoComplete={'off'} required name={inputName} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'profile:factorNameHint'} />
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button type={'submit'}>
|
||||
<Trans i18nKey={'profile:factorNameSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function QrImage({ src }: { src: string }) {
|
||||
return <Image alt={'QR Code'} src={src} width={160} height={160} />;
|
||||
}
|
||||
|
||||
export default MultiFactorAuthSetupModal;
|
||||
|
||||
function useEnrollFactor() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = useFactorsMutationKey();
|
||||
|
||||
const mutationFn = async (factorName: string) => {
|
||||
const { data, error } = await client.auth.mfa.enroll({
|
||||
friendlyName: factorName,
|
||||
factorType: 'totp',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
});
|
||||
}
|
||||
|
||||
function useVerifyCodeMutation() {
|
||||
const mutationKey = useFactorsMutationKey();
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationFn = async (params: { factorId: string; code: string }) => {
|
||||
const challenge = await client.auth.mfa.challenge({
|
||||
factorId: params.factorId,
|
||||
});
|
||||
|
||||
if (challenge.error) {
|
||||
throw challenge.error;
|
||||
}
|
||||
|
||||
const challengeId = challenge.data.id;
|
||||
|
||||
const verify = await client.auth.mfa.verify({
|
||||
factorId: params.factorId,
|
||||
code: params.code,
|
||||
challengeId,
|
||||
});
|
||||
|
||||
if (verify.error) {
|
||||
throw verify.error;
|
||||
}
|
||||
|
||||
return verify;
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
usePersonalAccountData,
|
||||
useRevalidatePersonalAccountDataQuery,
|
||||
} from '../../hooks/use-personal-account-data';
|
||||
import { UpdateAccountDetailsForm } from './update-account-details-form';
|
||||
|
||||
export function UpdateAccountDetailsFormContainer() {
|
||||
const user = usePersonalAccountData();
|
||||
const invalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
if (!user.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdateAccountDetailsForm
|
||||
displayName={user.data.name ?? ''}
|
||||
userId={user.data.id}
|
||||
onUpdate={invalidateUserDataQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useUpdateAccountData } from '../../hooks/use-update-account';
|
||||
|
||||
type UpdateUserDataParams = Database['public']['Tables']['accounts']['Update'];
|
||||
|
||||
const AccountInfoSchema = z.object({
|
||||
displayName: z.string().min(2).max(100),
|
||||
});
|
||||
|
||||
export function UpdateAccountDetailsForm({
|
||||
displayName,
|
||||
onUpdate,
|
||||
}: {
|
||||
displayName: string;
|
||||
userId: string;
|
||||
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
|
||||
}) {
|
||||
const updateAccountMutation = useUpdateAccountData();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AccountInfoSchema),
|
||||
defaultValues: {
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = ({ displayName }: { displayName: string }) => {
|
||||
const data = { name: displayName };
|
||||
|
||||
const promise = updateAccountMutation.mutateAsync(data).then(() => {
|
||||
onUpdate(data);
|
||||
});
|
||||
|
||||
return toast.promise(promise, {
|
||||
success: t(`profile:updateProfileSuccess`),
|
||||
error: t(`profile:updateProfileError`),
|
||||
loading: t(`profile:updateProfileLoading`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-profile-form'}
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name={'displayName'}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:displayNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'profile-display-name'}
|
||||
minLength={2}
|
||||
placeholder={''}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateAccountMutation.isPending}>
|
||||
<Trans i18nKey={'profile:updateProfileSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import ImageUploader from '@kit/ui/image-uploader';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import {
|
||||
usePersonalAccountData,
|
||||
useRevalidatePersonalAccountDataQuery,
|
||||
} from '../../hooks/use-personal-account-data';
|
||||
|
||||
const AVATARS_BUCKET = 'account_image';
|
||||
|
||||
export function UpdateAccountImageContainer() {
|
||||
const accountData = usePersonalAccountData();
|
||||
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
if (!accountData.data) {
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadProfileAvatarForm
|
||||
currentPhotoURL={accountData.data.picture_url}
|
||||
userId={accountData.data.id}
|
||||
onAvatarUpdated={revalidateUserDataQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadProfileAvatarForm(props: {
|
||||
currentPhotoURL: string | null;
|
||||
userId: string;
|
||||
onAvatarUpdated: () => void;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const { t } = useTranslation('profile');
|
||||
|
||||
const createToaster = useCallback(
|
||||
(promise: Promise<unknown>) => {
|
||||
return toast.promise(promise, {
|
||||
success: t(`updateProfileSuccess`),
|
||||
error: t(`updateProfileError`),
|
||||
loading: t(`updateProfileLoading`),
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(file: File | null) => {
|
||||
const removeExistingStorageFile = () => {
|
||||
if (props.currentPhotoURL) {
|
||||
return (
|
||||
deleteProfilePhoto(client, props.currentPhotoURL) ??
|
||||
Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
if (file) {
|
||||
const promise = removeExistingStorageFile().then(() =>
|
||||
uploadUserProfilePhoto(client, file, props.userId)
|
||||
.then((pictureUrl) => {
|
||||
return client
|
||||
.from('accounts')
|
||||
.update({
|
||||
picture_url: pictureUrl,
|
||||
})
|
||||
.eq('id', props.userId)
|
||||
.throwOnError();
|
||||
})
|
||||
.then(() => {
|
||||
props.onAvatarUpdated();
|
||||
}),
|
||||
);
|
||||
|
||||
createToaster(promise);
|
||||
} else {
|
||||
const promise = removeExistingStorageFile()
|
||||
.then(() => {
|
||||
return client
|
||||
.from('accounts')
|
||||
.update({
|
||||
picture_url: null,
|
||||
})
|
||||
.eq('id', props.userId)
|
||||
.throwOnError();
|
||||
})
|
||||
.then(() => {
|
||||
props.onAvatarUpdated();
|
||||
});
|
||||
|
||||
createToaster(promise);
|
||||
}
|
||||
},
|
||||
[client, createToaster, props],
|
||||
);
|
||||
|
||||
return (
|
||||
<ImageUploader value={props.currentPhotoURL} onValueChange={onValueChange}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm'}>
|
||||
<Trans i18nKey={'profile:profilePictureHeading'} />
|
||||
</span>
|
||||
|
||||
<span className={'text-xs'}>
|
||||
<Trans i18nKey={'profile:profilePictureSubheading'} />
|
||||
</span>
|
||||
</div>
|
||||
</ImageUploader>
|
||||
);
|
||||
}
|
||||
|
||||
function deleteProfilePhoto(client: SupabaseClient, url: string) {
|
||||
const bucket = client.storage.from(AVATARS_BUCKET);
|
||||
const fileName = url.split('/').pop()?.split('?')[0];
|
||||
|
||||
if (!fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
return bucket.remove([fileName]);
|
||||
}
|
||||
|
||||
async function uploadUserProfilePhoto(
|
||||
client: SupabaseClient,
|
||||
photoFile: File,
|
||||
userId: string,
|
||||
) {
|
||||
const bytes = await photoFile.arrayBuffer();
|
||||
const bucket = client.storage.from(AVATARS_BUCKET);
|
||||
const extension = photoFile.name.split('.').pop();
|
||||
const fileName = await getAvatarFileName(userId, extension);
|
||||
|
||||
const result = await bucket.upload(fileName, bytes, {
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
return bucket.getPublicUrl(fileName).data.publicUrl;
|
||||
}
|
||||
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
async function getAvatarFileName(
|
||||
userId: string,
|
||||
extension: string | undefined,
|
||||
) {
|
||||
const { nanoid } = await import('nanoid');
|
||||
const uniqueId = nanoid(16);
|
||||
|
||||
return `${userId}.${extension}?v=${uniqueId}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
|
||||
import { UpdateEmailForm } from './update-email-form';
|
||||
|
||||
export function UpdateEmailFormContainer(props: { callbackPath: string }) {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UpdateEmailForm callbackPath={props.callbackPath} user={user} />;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
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 { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
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';
|
||||
|
||||
const UpdateEmailSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
repeatEmail: z.string().email(),
|
||||
})
|
||||
.refine(
|
||||
(values) => {
|
||||
return values.email === values.repeatEmail;
|
||||
},
|
||||
{
|
||||
path: ['repeatEmail'],
|
||||
message: 'Emails do not match',
|
||||
},
|
||||
);
|
||||
|
||||
function createEmailResolver(currentEmail: string) {
|
||||
return zodResolver(
|
||||
UpdateEmailSchema.refine(
|
||||
(values) => {
|
||||
return values.email !== currentEmail;
|
||||
},
|
||||
{
|
||||
path: ['email'],
|
||||
message: 'New email must be different from current email',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateEmailForm({
|
||||
user,
|
||||
callbackPath,
|
||||
}: {
|
||||
user: User;
|
||||
callbackPath: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const updateUserMutation = useUpdateUser();
|
||||
|
||||
const updateEmail = useCallback(
|
||||
(email: string) => {
|
||||
const redirectTo = new URL(callbackPath, window.location.host).toString();
|
||||
|
||||
// then, we update the user's email address
|
||||
const promise = updateUserMutation.mutateAsync({ email, redirectTo });
|
||||
|
||||
return toast.promise(promise, {
|
||||
success: t(`profile:updateEmailSuccess`),
|
||||
loading: t(`profile:updateEmailLoading`),
|
||||
error: (error: Error) => {
|
||||
return error.message ?? t(`profile:updateEmailError`);
|
||||
},
|
||||
});
|
||||
},
|
||||
[callbackPath, t, updateUserMutation],
|
||||
);
|
||||
|
||||
const currentEmail = user.email;
|
||||
|
||||
const form = useForm({
|
||||
resolver: createEmailResolver(currentEmail!),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
repeatEmail: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
data-test={'update-email-form'}
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
return updateEmail(values.email);
|
||||
})}
|
||||
>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:updateEmailSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:updateEmailSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:newEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'profile-new-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:repeatEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
data-test={'profile-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button>
|
||||
<Trans i18nKey={'profile:updateEmailSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert } from '@kit/ui/alert';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdatePasswordForm } from './update-password-form';
|
||||
|
||||
export function UpdatePasswordFormContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
callbackPath: string;
|
||||
}>,
|
||||
) {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUpdatePassword = user.identities?.some(
|
||||
(item) => item.provider === `email`,
|
||||
);
|
||||
|
||||
if (!canUpdatePassword) {
|
||||
return <WarnCannotUpdatePasswordAlert />;
|
||||
}
|
||||
|
||||
return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />;
|
||||
}
|
||||
|
||||
function WarnCannotUpdatePasswordAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<Trans i18nKey={'profile:cannotUpdatePassword'} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
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 { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const PasswordUpdateSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(8).max(99),
|
||||
newPassword: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine(
|
||||
(values) => {
|
||||
return values.newPassword === values.repeatPassword;
|
||||
},
|
||||
{
|
||||
path: ['repeatPassword'],
|
||||
message: 'Passwords do not match',
|
||||
},
|
||||
);
|
||||
|
||||
export const UpdatePasswordForm = ({
|
||||
user,
|
||||
callbackPath,
|
||||
}: {
|
||||
user: User;
|
||||
callbackPath: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const [needsReauthentication, setNeedsReauthentication] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordUpdateSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const updatePasswordFromCredential = useCallback(
|
||||
(password: string) => {
|
||||
const redirectTo = [window.location.origin, callbackPath].join('');
|
||||
|
||||
const promise = updateUserMutation
|
||||
.mutateAsync({ password, redirectTo })
|
||||
.then(() => {
|
||||
form.reset();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.includes('Password update requires reauthentication')) {
|
||||
setNeedsReauthentication(true);
|
||||
}
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
success: t(`profile:updatePasswordSuccess`),
|
||||
error: t(`profile:updatePasswordError`),
|
||||
loading: t(`profile:updatePasswordLoading`),
|
||||
});
|
||||
},
|
||||
[callbackPath, updateUserMutation, t, form],
|
||||
);
|
||||
|
||||
const updatePasswordCallback = useCallback(
|
||||
async ({ newPassword }: { newPassword: string }) => {
|
||||
const email = user.email;
|
||||
|
||||
// if the user does not have an email assigned, it's possible they
|
||||
// don't have an email/password factor linked, and the UI is out of sync
|
||||
if (!email) {
|
||||
return Promise.reject(t(`profile:cannotUpdatePassword`));
|
||||
}
|
||||
|
||||
updatePasswordFromCredential(newPassword);
|
||||
},
|
||||
[user.email, updatePasswordFromCredential, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-password-form'}
|
||||
onSubmit={form.handleSubmit(updatePasswordCallback)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={needsReauthentication}>
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:needsReauthentication'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:needsReauthenticationDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:newPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:repeatPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'repeat-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button>
|
||||
<Trans i18nKey={'profile:updatePasswordSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
const queryKey = ['personal-account:data'];
|
||||
|
||||
export function usePersonalAccountData() {
|
||||
const client = useSupabase();
|
||||
|
||||
const queryFn = async () => {
|
||||
const { data, error } = await client.auth.getSession();
|
||||
|
||||
if (!data.session || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
picture_url
|
||||
`,
|
||||
)
|
||||
.eq('primary_owner_user_id', data.session.user.id)
|
||||
.eq('is_personal_account', true)
|
||||
.single();
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevalidatePersonalAccountDataQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
}),
|
||||
[queryClient],
|
||||
);
|
||||
}
|
||||
29
packages/features/accounts/src/hooks/use-update-account.ts
Normal file
29
packages/features/accounts/src/hooks/use-update-account.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
type UpdateData = Database['public']['Tables']['accounts']['Update'];
|
||||
|
||||
export function useUpdateAccountData(accountId: string) {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['account:data', accountId];
|
||||
|
||||
const mutationFn = async (data: UpdateData) => {
|
||||
const response = await client.from('accounts').update(data).match({
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateOrganizationAccountSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { CreateOrganizationAccountSchema } from '../schema/create-organization.schema';
|
||||
import { AccountsService } from './services/accounts.service';
|
||||
|
||||
const ORGANIZATION_ACCOUNTS_PATH = z
|
||||
.string({
|
||||
required_error: 'Organization accounts path is required',
|
||||
})
|
||||
.min(1)
|
||||
.parse(process.env.ORGANIZATION_ACCOUNTS_PATH);
|
||||
|
||||
export async function createOrganizationAccountAction(
|
||||
params: z.infer<typeof CreateOrganizationAccountSchema>,
|
||||
) {
|
||||
const { name: accountName } = CreateOrganizationAccountSchema.parse(params);
|
||||
|
||||
const client = getSupabaseServerActionClient();
|
||||
const accountsService = new AccountsService(client);
|
||||
const session = await requireAuth(client);
|
||||
|
||||
if (session.error) {
|
||||
redirect(session.redirectTo);
|
||||
}
|
||||
|
||||
const createAccountResponse =
|
||||
await accountsService.createNewOrganizationAccount({
|
||||
name: accountName,
|
||||
userId: session.data.user.id,
|
||||
});
|
||||
|
||||
if (createAccountResponse.error) {
|
||||
return handleError(
|
||||
createAccountResponse.error,
|
||||
`Error creating organization`,
|
||||
);
|
||||
}
|
||||
|
||||
const accountHomePath =
|
||||
ORGANIZATION_ACCOUNTS_PATH + createAccountResponse.data.slug;
|
||||
|
||||
redirect(accountHomePath);
|
||||
}
|
||||
|
||||
function handleError<Error = unknown>(
|
||||
error: Error,
|
||||
message: string,
|
||||
organizationId?: string,
|
||||
) {
|
||||
const exception = error instanceof Error ? error.message : undefined;
|
||||
|
||||
Logger.error(
|
||||
{
|
||||
exception,
|
||||
organizationId,
|
||||
},
|
||||
message,
|
||||
);
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name AccountsService
|
||||
* @description Service for managing accounts in the application
|
||||
* @param Database - The Supabase database type to use
|
||||
* @example
|
||||
* const client = getSupabaseClient();
|
||||
* const accountsService = new AccountsService(client);
|
||||
*
|
||||
* accountsService.createNewOrganizationAccount({
|
||||
* name: 'My Organization',
|
||||
* userId: '123',
|
||||
* });
|
||||
*/
|
||||
export class AccountsService {
|
||||
private readonly logger = new AccountsServiceLogger();
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
createNewOrganizationAccount(params: { name: string; userId: string }) {
|
||||
this.logger.logCreateNewOrganizationAccount(params);
|
||||
|
||||
return this.client.rpc('create_account', {
|
||||
account_name: params.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AccountsServiceLogger {
|
||||
private namespace = 'accounts';
|
||||
|
||||
logCreateNewOrganizationAccount(params: { name: string; userId: string }) {
|
||||
Logger.info(
|
||||
this.withNamespace(params),
|
||||
`Creating new organization account...`,
|
||||
);
|
||||
}
|
||||
|
||||
private withNamespace(params: object) {
|
||||
return { ...params, name: this.namespace };
|
||||
}
|
||||
}
|
||||
8
packages/features/accounts/tsconfig.json
Normal file
8
packages/features/accounts/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
18
packages/features/admin/package.json
Normal file
18
packages/features/admin/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@kit/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint ",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0"
|
||||
}
|
||||
}
|
||||
77
packages/features/admin/src/components/AdminDashboard.tsx
Normal file
77
packages/features/admin/src/components/AdminDashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface Data {
|
||||
usersCount: number;
|
||||
organizationsCount: number;
|
||||
activeSubscriptions: number;
|
||||
trialSubscriptions: number;
|
||||
}
|
||||
|
||||
function AdminDashboard({
|
||||
data,
|
||||
}: React.PropsWithChildren<{
|
||||
data: Data;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3' +
|
||||
' xl:grid-cols-4'
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.usersCount}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.organizationsCount}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Paying Customers</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.activeSubscriptions}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trials</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.trialSubscriptions}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
function Figure(props: React.PropsWithChildren) {
|
||||
return <div className={'text-3xl font-bold'}>{props.children}</div>;
|
||||
}
|
||||
22
packages/features/admin/src/components/AdminGuard.tsx
Normal file
22
packages/features/admin/src/components/AdminGuard.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import isUserSuperAdmin from '../../../app/admin/utils/is-user-super-admin';
|
||||
|
||||
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
|
||||
|
||||
function AdminGuard<Params extends object>(
|
||||
Component: LayoutOrPageComponent<Params>,
|
||||
) {
|
||||
return async function AdminGuardServerComponentWrapper(params: Params) {
|
||||
const isAdmin = await isUserSuperAdmin();
|
||||
|
||||
// if the user is not a super-admin, we redirect to a 404
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <Component {...params} />;
|
||||
};
|
||||
}
|
||||
|
||||
export default AdminGuard;
|
||||
27
packages/features/admin/src/components/AdminHeader.tsx
Normal file
27
packages/features/admin/src/components/AdminHeader.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import pathsConfig from '@/config/paths.config';
|
||||
|
||||
import { PageHeader } from '@/components/app/Page';
|
||||
|
||||
function AdminHeader({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<PageHeader
|
||||
title={children}
|
||||
description={`Manage your app from the admin dashboard.`}
|
||||
>
|
||||
<Link href={pathsConfig.appHome}>
|
||||
<Button variant={'link'}>
|
||||
<ArrowLeftIcon className={'h-4'} />
|
||||
<span>Back to App</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminHeader;
|
||||
38
packages/features/admin/src/components/AdminSidebar.tsx
Normal file
38
packages/features/admin/src/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { HomeIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
import Logo from '@/components/app/Logo';
|
||||
import { Sidebar, SidebarContent, SidebarItem } from '@/components/app/Sidebar';
|
||||
|
||||
function AdminSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent className={'mb-6 mt-4 pt-2'}>
|
||||
<Logo href={'/admin'} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarItem end path={'/admin'} Icon={<HomeIcon className={'h-4'} />}>
|
||||
Admin
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
path={'/admin/users'}
|
||||
Icon={<UserIcon className={'h-4'} />}
|
||||
>
|
||||
Users
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
path={'/admin/organizations'}
|
||||
Icon={<UsersIcon className={'h-4'} />}
|
||||
>
|
||||
Organizations
|
||||
</SidebarItem>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSidebar;
|
||||
10
packages/features/admin/tsconfig.json
Normal file
10
packages/features/admin/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
47
packages/features/auth/package.json
Normal file
47
packages/features/auth/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@kit/auth",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./sign-in": "./src/sign-in.ts",
|
||||
"./sign-up": "./src/sign-up.ts",
|
||||
"./password-reset": "./src/password-reset.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./mfa": "./src/mfa.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"react-i18next": "14.1.0",
|
||||
"sonner": "^1.4.41",
|
||||
"@tanstack/react-query": "5.28.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
46
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
/**
|
||||
* @name AuthErrorAlert
|
||||
* @param error This error comes from Supabase as the code returned on errors
|
||||
* This error is mapped from the translation auth:errors.{error}
|
||||
* To update the error messages, please update the translation file
|
||||
* https://github.com/supabase/gotrue-js/blob/master/src/lib/errors.ts
|
||||
* @constructor
|
||||
*/
|
||||
export function AuthErrorAlert({
|
||||
error,
|
||||
}: {
|
||||
error: Error | null | undefined | string;
|
||||
}) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DefaultError = <Trans i18nKey="auth:errors.default" />;
|
||||
const errorCode = error instanceof Error ? error.message : error;
|
||||
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={`auth:errorAlertHeading`} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription
|
||||
className={'text-sm font-medium'}
|
||||
data-test={'auth-error-message'}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`auth:errors.${errorCode}`}
|
||||
defaults={'<DefaultError />'}
|
||||
components={{ DefaultError }}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
24
packages/features/auth/src/components/auth-layout.tsx
Normal file
24
packages/features/auth/src/components/auth-layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export function AuthLayoutShell({
|
||||
children,
|
||||
Logo,
|
||||
}: React.PropsWithChildren<{
|
||||
Logo: React.ComponentType;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-screen flex-col items-center justify-center space-y-4' +
|
||||
' dark:lg:bg-background md:space-y-8 lg:space-y-12 lg:bg-gray-50' +
|
||||
' animate-in fade-in slide-in-from-top-8 duration-1000'
|
||||
}
|
||||
>
|
||||
{Logo && <Logo />}
|
||||
|
||||
<div
|
||||
className={`bg-background dark:border-border flex w-full max-w-sm flex-col items-center space-y-4 rounded-lg border-transparent md:w-8/12 md:border md:px-8 md:py-6 md:shadow lg:w-5/12 lg:px-6 xl:w-4/12 2xl:w-3/12`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
packages/features/auth/src/components/auth-link-redirect.tsx
Normal file
34
packages/features/auth/src/components/auth-link-redirect.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function AuthLinkRedirect(props: { redirectPath?: string }) {
|
||||
const params = useSearchParams();
|
||||
|
||||
const redirectPath = params?.get('redirectPath') ?? props.redirectPath ?? '/';
|
||||
|
||||
useRedirectOnSignIn(redirectPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AuthLinkRedirect;
|
||||
|
||||
function useRedirectOnSignIn(redirectPath: string) {
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange((_, session) => {
|
||||
if (session) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, router, redirectPath]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { OauthProviderLogoImage } from './oauth-provider-logo-image';
|
||||
|
||||
export function AuthProviderButton({
|
||||
providerId,
|
||||
onClick,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
providerId: string;
|
||||
onClick: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<Button
|
||||
className={'flex w-full space-x-2 text-center'}
|
||||
data-provider={providerId}
|
||||
data-test={'auth-provider-button'}
|
||||
variant={'outline'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<OauthProviderLogoImage providerId={providerId} />
|
||||
|
||||
<span>{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
137
packages/features/auth/src/components/email-otp-container.tsx
Normal file
137
packages/features/auth/src/components/email-otp-container.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { OtpInput } from '@kit/ui/otp-input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function EmailOtpContainer({
|
||||
shouldCreateUser,
|
||||
onSignIn,
|
||||
inviteCode,
|
||||
redirectUrl,
|
||||
}: React.PropsWithChildren<{
|
||||
inviteCode?: string;
|
||||
redirectUrl: string;
|
||||
shouldCreateUser: boolean;
|
||||
onSignIn?: () => void;
|
||||
}>) {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
if (email) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
redirectUrl={redirectUrl}
|
||||
inviteCode={inviteCode}
|
||||
onSuccess={onSignIn}
|
||||
email={email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmailOtpForm onSuccess={setEmail} shouldCreateUser={shouldCreateUser} />
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyOtpForm({
|
||||
email,
|
||||
inviteCode,
|
||||
onSuccess,
|
||||
redirectUrl,
|
||||
}: {
|
||||
email: string;
|
||||
redirectUrl: string;
|
||||
onSuccess?: () => void;
|
||||
inviteCode?: string;
|
||||
}) {
|
||||
const verifyOtpMutation = useVerifyOtp();
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
|
||||
const redirectTo = [redirectUrl, queryParams].join('');
|
||||
|
||||
await verifyOtpMutation.mutateAsync({
|
||||
email,
|
||||
token: verifyCode,
|
||||
type: 'email',
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess && onSuccess();
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<OtpInput onValid={setVerifyCode} onInvalid={() => setVerifyCode('')} />
|
||||
|
||||
<Button disabled={verifyOtpMutation.isPending || !verifyCode}>
|
||||
{verifyOtpMutation.isPending ? (
|
||||
<Trans i18nKey={'profile:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'profile:submitVerificationCode'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailOtpForm({
|
||||
shouldCreateUser,
|
||||
onSuccess,
|
||||
}: React.PropsWithChildren<{
|
||||
shouldCreateUser: boolean;
|
||||
onSuccess: (email: string) => void;
|
||||
}>) {
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const email = event.currentTarget.email.value;
|
||||
|
||||
await signInWithOtpMutation.mutateAsync({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser,
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess(email);
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'auth:emailAddress'} />
|
||||
<Input name={'email'} type={'email'} placeholder={''} />
|
||||
</Label>
|
||||
|
||||
<Button disabled={signInWithOtpMutation.isPending}>
|
||||
<If
|
||||
condition={signInWithOtpMutation.isPending}
|
||||
fallback={<Trans i18nKey={'auth:sendEmailCode'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:sendingEmailCode'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function MagicLinkAuthContainer({
|
||||
inviteCode,
|
||||
redirectUrl,
|
||||
}: {
|
||||
inviteCode?: string;
|
||||
redirectUrl: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const target = event.currentTarget;
|
||||
const data = new FormData(target);
|
||||
const email = data.get('email') as string;
|
||||
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
|
||||
|
||||
const emailRedirectTo = [redirectUrl, queryParams].join('');
|
||||
|
||||
const promise = signInWithOtpMutation.mutateAsync({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
},
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t('auth:sendingEmailLink'),
|
||||
success: t(`auth:sendLinkSuccessToast`),
|
||||
error: t(`auth:errors.link`),
|
||||
});
|
||||
},
|
||||
[inviteCode, redirectUrl, signInWithOtpMutation, t],
|
||||
);
|
||||
|
||||
if (signInWithOtpMutation.data) {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:sendLinkSuccess'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={'w-full'} onSubmit={onSubmit}>
|
||||
<If condition={signInWithOtpMutation.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:errors.link'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('auth:emailPlaceholder')}
|
||||
name={'email'}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Button disabled={signInWithOtpMutation.isPending}>
|
||||
<If
|
||||
condition={signInWithOtpMutation.isPending}
|
||||
fallback={<Trans i18nKey={'auth:sendEmailLink'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:sendingEmailLink'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import useFetchAuthFactors from '@kit/supabase/hooks/use-fetch-mfa-factors';
|
||||
import useSignOut from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { OtpInput } from '@kit/ui/otp-input';
|
||||
import Spinner from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function MultiFactorChallengeContainer({
|
||||
onSuccess,
|
||||
}: React.PropsWithChildren<{
|
||||
onSuccess: () => void;
|
||||
}>) {
|
||||
const [factorId, setFactorId] = useState('');
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const verifyMFAChallenge = useVerifyMFAChallenge();
|
||||
|
||||
const onSubmitClicked: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(event) => {
|
||||
void (async () => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!factorId || !verifyCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyMFAChallenge.mutateAsync({
|
||||
factorId,
|
||||
verifyCode,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
})();
|
||||
},
|
||||
[factorId, verifyMFAChallenge, onSuccess, verifyCode],
|
||||
);
|
||||
|
||||
if (!factorId) {
|
||||
return (
|
||||
<FactorsListContainer onSelect={setFactorId} onSuccess={onSuccess} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmitClicked}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<span className={'text-sm'}>
|
||||
<Trans i18nKey={'profile:verifyActivationCodeDescription'} />
|
||||
</span>
|
||||
|
||||
<div className={'flex w-full flex-col space-y-2.5'}>
|
||||
<OtpInput
|
||||
onInvalid={() => setVerifyCode('')}
|
||||
onValid={setVerifyCode}
|
||||
/>
|
||||
|
||||
<If condition={verifyMFAChallenge.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:invalidVerificationCode'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<Button disabled={verifyMFAChallenge.isPending || !verifyCode}>
|
||||
{verifyMFAChallenge.isPending ? (
|
||||
<Trans i18nKey={'profile:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'profile:submitVerificationCode'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function useVerifyMFAChallenge() {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['mfa-verify-challenge'];
|
||||
const mutationFn = async (params: {
|
||||
factorId: string;
|
||||
verifyCode: string;
|
||||
}) => {
|
||||
const { factorId, verifyCode: code } = params;
|
||||
|
||||
const response = await client.auth.mfa.challengeAndVerify({
|
||||
factorId,
|
||||
code,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
|
||||
function FactorsListContainer({
|
||||
onSuccess,
|
||||
onSelect,
|
||||
}: React.PropsWithChildren<{
|
||||
onSuccess: () => void;
|
||||
onSelect: (factor: string) => void;
|
||||
}>) {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const { data: factors, isLoading, error } = useFetchAuthFactors();
|
||||
|
||||
const isSuccess = factors && !isLoading && !error;
|
||||
|
||||
useEffect(() => {
|
||||
// If there are no factors, continue
|
||||
if (isSuccess && !factors.totp.length) {
|
||||
onSuccess();
|
||||
}
|
||||
}, [factors?.totp.length, isSuccess, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is an error, sign out
|
||||
if (error) {
|
||||
void signOut.mutateAsync();
|
||||
}
|
||||
}, [error, signOut]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is only one factor, select it automatically
|
||||
if (isSuccess && factors.totp.length === 1) {
|
||||
const factorId = factors.totp[0]?.id;
|
||||
|
||||
if (factorId) {
|
||||
onSelect(factorId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={'flex flex-col items-center space-y-4 py-8'}>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'profile:loadingFactors'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'w-full'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:factorsListError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verifiedFactors = factors?.totp ?? [];
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<Heading level={6}>
|
||||
<Trans i18nKey={'profile:selectFactor'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
{verifiedFactors.map((factor) => (
|
||||
<div key={factor.id}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className={'w-full border-gray-50'}
|
||||
onClick={() => onSelect(factor.id)}
|
||||
>
|
||||
{factor.friendly_name}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { AtSignIcon, PhoneIcon } from 'lucide-react';
|
||||
|
||||
const DEFAULT_IMAGE_SIZE = 18;
|
||||
|
||||
export const OauthProviderLogoImage: React.FC<{
|
||||
providerId: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}> = ({ providerId, width, height }) => {
|
||||
const image = getOAuthProviderLogos()[providerId];
|
||||
|
||||
if (typeof image === `string`) {
|
||||
return (
|
||||
<Image
|
||||
decoding={'async'}
|
||||
loading={'lazy'}
|
||||
src={image}
|
||||
alt={`${providerId} logo`}
|
||||
width={width ?? DEFAULT_IMAGE_SIZE}
|
||||
height={height ?? DEFAULT_IMAGE_SIZE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{image}</>;
|
||||
};
|
||||
|
||||
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
||||
return {
|
||||
password: <AtSignIcon className={'s-[18px]'} />,
|
||||
phone: <PhoneIcon className={'s-[18px]'} />,
|
||||
google: '/assets/images/google.webp',
|
||||
facebook: '/assets/images/facebook.webp',
|
||||
twitter: '/assets/images/twitter.webp',
|
||||
github: '/assets/images/github.webp',
|
||||
microsoft: '/assets/images/microsoft.webp',
|
||||
apple: '/assets/images/apple.webp',
|
||||
};
|
||||
}
|
||||
113
packages/features/auth/src/components/oauth-providers.tsx
Normal file
113
packages/features/auth/src/components/oauth-providers.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { useSignInWithProvider } from '@kit/supabase/hooks/use-sign-in-with-provider';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { AuthProviderButton } from './auth-provider-button';
|
||||
|
||||
export const OauthProviders: React.FC<{
|
||||
returnUrl?: string;
|
||||
inviteCode?: string;
|
||||
enabledProviders: Provider[];
|
||||
redirectUrl: string;
|
||||
}> = (props) => {
|
||||
const signInWithProviderMutation = useSignInWithProvider();
|
||||
|
||||
// we make the UI "busy" until the next page is fully loaded
|
||||
const loading = signInWithProviderMutation.isPending;
|
||||
|
||||
const onSignInWithProvider = useCallback(
|
||||
async (signInRequest: () => Promise<unknown>) => {
|
||||
const credential = await signInRequest();
|
||||
|
||||
if (!credential) {
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const enabledProviders = props.enabledProviders;
|
||||
|
||||
if (!enabledProviders?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={loading}>
|
||||
<LoadingOverlay />
|
||||
</If>
|
||||
|
||||
<div className={'flex w-full flex-1 flex-col space-y-3'}>
|
||||
<div className={'flex-col space-y-2'}>
|
||||
{enabledProviders.map((provider) => {
|
||||
return (
|
||||
<AuthProviderButton
|
||||
key={provider}
|
||||
providerId={provider}
|
||||
onClick={() => {
|
||||
const origin = window.location.origin;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (props.returnUrl) {
|
||||
queryParams.set('next', props.returnUrl);
|
||||
}
|
||||
|
||||
if (props.inviteCode) {
|
||||
queryParams.set('inviteCode', props.inviteCode);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.redirectUrl,
|
||||
queryParams.toString(),
|
||||
].join('?');
|
||||
|
||||
const redirectTo = [origin, redirectPath].join('');
|
||||
|
||||
const credentials = {
|
||||
provider,
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
};
|
||||
|
||||
return onSignInWithProvider(() =>
|
||||
signInWithProviderMutation.mutateAsync(credentials),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
/>
|
||||
</AuthProviderButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AuthErrorAlert error={signInWithProviderMutation.error} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getProviderName(providerId: string) {
|
||||
const capitalize = (value: string) =>
|
||||
value.slice(0, 1).toUpperCase() + value.slice(1);
|
||||
|
||||
if (providerId.endsWith('.com')) {
|
||||
return capitalize(providerId.split('.com')[0]!);
|
||||
}
|
||||
|
||||
return capitalize(providerId);
|
||||
}
|
||||
154
packages/features/auth/src/components/password-reset-form.tsx
Normal file
154
packages/features/auth/src/components/password-reset-form.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordResetSchema } from '../schemas/password-reset.schema';
|
||||
|
||||
function PasswordResetForm(params: { redirectTo: string }) {
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordResetSchema>>({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (updateUser.error) {
|
||||
return <ErrorState onRetry={() => updateUser.reset()} />;
|
||||
}
|
||||
|
||||
if (updateUser.data && !updateUser.isPending) {
|
||||
return <SuccessState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-6'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<Heading level={5}>
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex w-full flex-1 flex-col'}
|
||||
onSubmit={form.handleSubmit(({ password }) => {
|
||||
return updateUser.mutateAsync({
|
||||
password,
|
||||
redirectTo: params.redirectTo,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className={'flex-col space-y-4'}>
|
||||
<FormField
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input required type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:repeatPassword'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input required type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={updateUser.isPending}
|
||||
type="submit"
|
||||
className={'w-full'}
|
||||
>
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordResetForm;
|
||||
|
||||
function SuccessState() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Link href={'/'}>
|
||||
<Button variant={'outline'}>
|
||||
<Trans i18nKey={'common:backToHomePage'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState(props: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:resetPasswordError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={props.onRetry} variant={'outline'}>
|
||||
<Trans i18nKey={'common:retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
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 { AuthErrorAlert } from './auth-error-alert';
|
||||
|
||||
const PasswordResetSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export function PasswordResetRequestContainer(params: { redirectTo: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
const resetPasswordMutation = useRequestResetPassword();
|
||||
const error = resetPasswordMutation.error;
|
||||
const success = resetPasswordMutation.data;
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordResetSchema>>({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={success}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:passwordResetSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!resetPasswordMutation.data}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(({ email }) => {
|
||||
return resetPasswordMutation.mutateAsync({
|
||||
email,
|
||||
redirectTo: params.redirectTo,
|
||||
});
|
||||
})}
|
||||
className={'w-full'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth:passwordResetSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<FormField
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button disabled={resetPasswordMutation.isPending} type="submit">
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
|
||||
|
||||
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignInForm } from './password-sign-in-form';
|
||||
|
||||
export const PasswordSignInContainer: React.FC<{
|
||||
onSignIn?: (userId?: string) => unknown;
|
||||
}> = ({ onSignIn }) => {
|
||||
const signInMutation = useSignInWithEmailPassword();
|
||||
const isLoading = signInMutation.isPending;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
|
||||
try {
|
||||
const data = await signInMutation.mutateAsync(credentials);
|
||||
const userId = data?.user?.id;
|
||||
|
||||
if (onSignIn) {
|
||||
onSignIn(userId);
|
||||
}
|
||||
} catch (e) {
|
||||
// wrong credentials, do nothing
|
||||
}
|
||||
},
|
||||
[onSignIn, signInMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthErrorAlert error={signInMutation.error} />
|
||||
|
||||
<PasswordSignInForm onSubmit={onSubmit} loading={isLoading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
120
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
120
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
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 { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
|
||||
export const PasswordSignInForm: React.FC<{
|
||||
onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown;
|
||||
loading: boolean;
|
||||
}> = ({ onSubmit, loading }) => {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordSignInSchema>>({
|
||||
resolver: zodResolver(PasswordSignInSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'w-full space-y-2.5'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<Link href={'/auth/password-reset'}>
|
||||
<Button
|
||||
type={'button'}
|
||||
size={'sm'}
|
||||
variant={'link'}
|
||||
className={'text-xs'}
|
||||
>
|
||||
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test="auth-submit-button"
|
||||
className={'w-full'}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
<If
|
||||
condition={loading}
|
||||
fallback={<Trans i18nKey={'auth:signInWithEmail'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:signingIn'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignUpForm } from './password-sign-up-form';
|
||||
|
||||
export function EmailPasswordSignUpContainer({
|
||||
onSignUp,
|
||||
onError,
|
||||
emailRedirectTo,
|
||||
}: React.PropsWithChildren<{
|
||||
onSignUp?: (userId?: string) => unknown;
|
||||
onError?: (error?: unknown) => unknown;
|
||||
emailRedirectTo: string;
|
||||
}>) {
|
||||
const signUpMutation = useSignUpWithEmailAndPassword();
|
||||
const redirecting = useRef(false);
|
||||
const loading = signUpMutation.isPending || redirecting.current;
|
||||
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
|
||||
|
||||
const callOnErrorCallback = useCallback(() => {
|
||||
if (signUpMutation.error && onError) {
|
||||
onError(signUpMutation.error);
|
||||
}
|
||||
}, [signUpMutation.error, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
callOnErrorCallback();
|
||||
}, [callOnErrorCallback]);
|
||||
|
||||
const onSignupRequested = useCallback(
|
||||
async (credentials: { email: string; password: string }) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await signUpMutation.mutateAsync({
|
||||
...credentials,
|
||||
emailRedirectTo,
|
||||
});
|
||||
|
||||
setShowVerifyEmailAlert(true);
|
||||
|
||||
if (onSignUp) {
|
||||
onSignUp(data.user?.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[emailRedirectTo, loading, onError, onSignUp, signUpMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={showVerifyEmailAlert}>
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'email-confirmation-alert'}>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!showVerifyEmailAlert}>
|
||||
<AuthErrorAlert error={signUpMutation.error} />
|
||||
|
||||
<PasswordSignUpForm onSubmit={onSignupRequested} loading={loading} />
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
140
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
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 { PasswordSignUpSchema } from '../schemas/password-sign-up.schema';
|
||||
|
||||
export const PasswordSignUpForm: React.FC<{
|
||||
onSubmit: (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
repeatPassword: string;
|
||||
}) => unknown;
|
||||
loading: boolean;
|
||||
}> = ({ onSubmit, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordSignUpSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'w-full space-y-2.5'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'auth:repeatPassword'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'repeat-password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<FormDescription className={'pb-2 text-xs'}>
|
||||
<Trans i18nKey={'auth:repeatPasswordHint'} />
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test={'auth-submit-button'}
|
||||
className={'w-full'}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
<If
|
||||
condition={loading}
|
||||
fallback={<Trans i18nKey={'auth:signUpWithEmail'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:signingUp'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function ResendAuthLinkForm() {
|
||||
const resendLink = useResendLink();
|
||||
|
||||
if (resendLink.data && !resendLink.isPending) {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:resendLinkSuccess'} defaults={'Success!'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'flex flex-col space-y-2'}
|
||||
onSubmit={(data) => {
|
||||
data.preventDefault();
|
||||
|
||||
const email = new FormData(data.currentTarget).get('email') as string;
|
||||
|
||||
return resendLink.mutateAsync(email);
|
||||
}}
|
||||
>
|
||||
<Label>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
<Input name={'email'} required placeholder={''} />
|
||||
</Label>
|
||||
|
||||
<Button disabled={resendLink.isPending}>
|
||||
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResendAuthLinkForm;
|
||||
|
||||
function useResendLink() {
|
||||
const supabase = useSupabase();
|
||||
|
||||
const mutationKey = ['resend-link'];
|
||||
const mutationFn = async (email: string) => {
|
||||
const response = await supabase.auth.resend({
|
||||
email,
|
||||
type: 'signup',
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@supabase/ssr';
|
||||
|
||||
import { Divider } from '@kit/ui/divider';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { EmailOtpContainer } from './email-otp-container';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||
|
||||
export function SignInMethodsContainer(props: {
|
||||
paths: {
|
||||
callback: string;
|
||||
home: string;
|
||||
};
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
otp: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
}) {
|
||||
const redirectUrl = new URL(
|
||||
props.paths.callback,
|
||||
isBrowser() ? window?.location.origin : '',
|
||||
).toString();
|
||||
|
||||
const router = useRouter();
|
||||
const onSignIn = () => router.replace(props.paths.home);
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.providers.password}>
|
||||
<PasswordSignInContainer onSignIn={onSignIn} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer redirectUrl={redirectUrl} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<EmailOtpContainer
|
||||
onSignIn={onSignIn}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={false}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<Divider />
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@supabase/ssr';
|
||||
|
||||
import { Divider } from '@kit/ui/divider';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { EmailOtpContainer } from './email-otp-container';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
|
||||
|
||||
export function SignUpMethodsContainer(props: {
|
||||
callbackPath: string;
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
otp: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
|
||||
inviteCode?: string;
|
||||
}) {
|
||||
const redirectUrl = new URL(
|
||||
props.callbackPath,
|
||||
isBrowser() ? window?.location.origin : '',
|
||||
).toString();
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.providers.password}>
|
||||
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteCode={props.inviteCode}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<EmailOtpContainer
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={true}
|
||||
inviteCode={props.inviteCode}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<Divider />
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
redirectUrl={redirectUrl}
|
||||
inviteCode={props.inviteCode}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
packages/features/auth/src/mfa.ts
Normal file
1
packages/features/auth/src/mfa.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/multi-factor-challenge-container';
|
||||
1
packages/features/auth/src/password-reset.ts
Normal file
1
packages/features/auth/src/password-reset.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/password-reset-request-container';
|
||||
11
packages/features/auth/src/schemas/password-reset.schema.ts
Normal file
11
packages/features/auth/src/schemas/password-reset.schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordResetSchema = z
|
||||
.object({
|
||||
password: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine((data) => data.password === data.repeatPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatPassword'],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordSignInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(99),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordSignUpSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.password === schema.repeatPassword;
|
||||
},
|
||||
{
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatPassword'],
|
||||
},
|
||||
);
|
||||
1
packages/features/auth/src/shared.ts
Normal file
1
packages/features/auth/src/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/auth-layout';
|
||||
2
packages/features/auth/src/sign-in.ts
Normal file
2
packages/features/auth/src/sign-in.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/sign-in-methods-container';
|
||||
export * from './schemas/password-sign-in.schema';
|
||||
2
packages/features/auth/src/sign-up.ts
Normal file
2
packages/features/auth/src/sign-up.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/sign-up-methods-container';
|
||||
export * from './schemas/password-sign-up.schema';
|
||||
8
packages/features/auth/tsconfig.json
Normal file
8
packages/features/auth/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
48
packages/features/team-accounts/package.json
Normal file
48
packages/features/team-accounts/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@kit/team-accounts",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/shared": "0.1.0",
|
||||
"@kit/accounts": "0.1.0",
|
||||
"@kit/mailers": "0.1.0",
|
||||
"@kit/emails": "0.1.0",
|
||||
"lucide-react": "^0.360.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
8
packages/features/team-accounts/tsconfig.json
Normal file
8
packages/features/team-accounts/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user