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

View File

@@ -0,0 +1,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/*"
]
}
}
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export * from './account-settings-container';

View File

@@ -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 });
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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}`;
}

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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],
);
}

View 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,
});
}

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const CreateOrganizationAccountSchema = z.object({
name: z.string().min(2).max(50),
});

View File

@@ -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);
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View 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"
}
}

View 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>;
}

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,10 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": [
"node_modules"
]
}

View 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/*"
]
}
}
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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',
};
}

View 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);
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />
</>
);
};

View 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>
);
};

View File

@@ -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>
</>
);
}

View 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>
);
};

View File

@@ -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,
});
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './components/multi-factor-challenge-container';

View File

@@ -0,0 +1 @@
export * from './components/password-reset-request-container';

View 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'],
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const PasswordSignInSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(99),
});

View File

@@ -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'],
},
);

View File

@@ -0,0 +1 @@
export * from './components/auth-layout';

View File

@@ -0,0 +1,2 @@
export * from './components/sign-in-methods-container';
export * from './schemas/password-sign-in.schema';

View File

@@ -0,0 +1,2 @@
export * from './components/sign-up-methods-container';
export * from './schemas/password-sign-up.schema';

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View 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/*"
]
}
}
}

View File

@@ -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`);
}
}

View File

@@ -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`);
}
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View 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';

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const DeleteTeamAccountSchema = z.object({
accountId: z.string(),
});

View File

@@ -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;
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const LeaveTeamAccountSchema = z.object({
accountId: z.string(),
userId: z.string(),
});

View File

@@ -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'],
});

View File

@@ -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)),
});

View File

@@ -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}`;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}