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