B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

17
packages/features/accounts/node_modules/.bin/nanoid generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/nanoid@5.1.5/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/nanoid@5.1.5/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.js" "$@"
else
exec node "$basedir/../nanoid/bin/nanoid.js" "$@"
fi

17
packages/features/accounts/node_modules/.bin/next generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
else
exec node "$basedir/../next/dist/bin/next" "$@"
fi

17
packages/features/accounts/node_modules/.bin/tsc generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsc" "$@"
else
exec node "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsc" "$@"
fi

17
packages/features/accounts/node_modules/.bin/tsserver generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsserver" "$@"
else
exec node "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsserver" "$@"
fi

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.57.0_react@19.1.0_/node_modules/@hookform/resolvers

View File

@@ -0,0 +1 @@
../../../../billing/gateway

View File

@@ -0,0 +1 @@
../../../../email-templates

View File

@@ -0,0 +1 @@
../../../../../tooling/eslint

1
packages/features/accounts/node_modules/@kit/mailers generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../mailers/core

1
packages/features/accounts/node_modules/@kit/monitoring generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../monitoring/api

1
packages/features/accounts/node_modules/@kit/next generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../next

1
packages/features/accounts/node_modules/@kit/otp generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../otp

View File

@@ -0,0 +1 @@
../../../../../tooling/prettier

1
packages/features/accounts/node_modules/@kit/shared generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../shared

1
packages/features/accounts/node_modules/@kit/supabase generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../supabase

1
packages/features/accounts/node_modules/@kit/tsconfig generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../tooling/typescript

1
packages/features/accounts/node_modules/@kit/ui generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../ui

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@19.1.0/node_modules/@radix-ui/react-icons

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@tanstack+react-query@5.76.1_react@19.1.0/node_modules/@tanstack/react-query

1
packages/features/accounts/node_modules/@types/react generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom

1
packages/features/accounts/node_modules/lucide-react generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/lucide-react@0.510.0_react@19.1.0/node_modules/lucide-react

1
packages/features/accounts/node_modules/nanoid generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid

1
packages/features/accounts/node_modules/next generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next

1
packages/features/accounts/node_modules/next-themes generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/next-themes@0.4.6_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/next-themes

1
packages/features/accounts/node_modules/react generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/react@19.1.0/node_modules/react

1
packages/features/accounts/node_modules/react-dom generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom

1
packages/features/accounts/node_modules/react-hook-form generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/react-hook-form@7.57.0_react@19.1.0/node_modules/react-hook-form

1
packages/features/accounts/node_modules/react-i18next generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/react-i18next@15.5.2_i18next@25.1.3_typescript@5.8.3__react-dom@19.1.0_react@19.1.0__react@19.1.0_typescript@5.8.3/node_modules/react-i18next

1
packages/features/accounts/node_modules/sonner generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/sonner@2.0.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/sonner

1
packages/features/accounts/node_modules/zod generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

View File

@@ -0,0 +1,59 @@
{
"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",
"./components": "./src/components/index.ts",
"./hooks/*": "./src/hooks/*.ts",
"./api": "./src/server/api.ts"
},
"dependencies": {
"nanoid": "^5.1.5"
},
"devDependencies": {
"@hookform/resolvers": "^5.0.1",
"@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.49.4",
"@tanstack/react-query": "5.76.1",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"lucide-react": "^0.510.0",
"next": "15.3.2",
"next-themes": "0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"sonner": "^2.0.3",
"zod": "^3.24.4"
},
"prettier": "@kit/prettier-config",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,293 @@
'use client';
import { useMemo, useState } from 'react';
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
import { CheckCircle, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
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 { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { CreateTeamAccountDialog } from '../../../team-accounts/src/components/create-team-account-dialog';
import { usePersonalAccountData } from '../hooks/use-personal-account-data';
interface AccountSelectorProps {
accounts: Array<{
label: string | null;
value: string | null;
image?: string | null;
}>;
features: {
enableTeamCreation: boolean;
};
userId: string;
selectedAccount?: string;
collapsed?: boolean;
className?: string;
collisionPadding?: number;
onAccountChange: (value: string | undefined) => void;
}
const PERSONAL_ACCOUNT_SLUG = 'personal';
export function AccountSelector({
accounts,
selectedAccount,
onAccountChange,
userId,
className,
features = {
enableTeamCreation: true,
},
collapsed = false,
collisionPadding = 20,
}: React.PropsWithChildren<AccountSelectorProps>) {
const [open, setOpen] = useState<boolean>(false);
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
const { t } = useTranslation('teams');
const personalData = usePersonalAccountData(userId);
const value = useMemo(() => {
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
}, [selectedAccount]);
const Icon = (props: { item: string }) => {
return (
<CheckCircle
className={cn(
'ml-auto h-4 w-4',
value === props.item ? 'opacity-100' : 'opacity-0',
)}
/>
);
};
const selected = accounts.find((account) => account.value === value);
const pictureUrl = personalData.data?.picture_url;
const PersonalAccountAvatar = () =>
pictureUrl ? (
<UserAvatar pictureUrl={pictureUrl} />
) : (
<PersonIcon className="h-5 w-5" />
);
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
data-test={'account-selector-trigger'}
size={collapsed ? 'icon' : 'default'}
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
'dark:shadow-primary/10 group w-full min-w-0 px-2 lg:w-auto lg:max-w-fit',
{
'justify-start': !collapsed,
'm-auto justify-center px-2 lg:w-full': collapsed,
},
className,
)}
>
<If
condition={selected}
fallback={
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-4': !collapsed,
})}
>
<PersonalAccountAvatar />
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
<Trans i18nKey={'teams:personalAccount'} />
</span>
</span>
}
>
{(account) => (
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-4': !collapsed,
})}
>
<Avatar className={'rounded-xs h-6 w-6'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={'group-hover:bg-background rounded-xs'}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
{account.label}
</span>
</span>
)}
</If>
<CaretSortIcon
className={cn('ml-2 h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
</Button>
</PopoverTrigger>
<PopoverContent
data-test={'account-selector-content'}
className="w-full p-0"
collisionPadding={collisionPadding}
>
<Command>
<CommandInput placeholder={t('searchAccount')} className="h-9" />
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => onAccountChange(undefined)}
value={PERSONAL_ACCOUNT_SLUG}
>
<PersonalAccountAvatar />
<span className={'ml-2'}>
<Trans i18nKey={'teams:personalAccount'} />
</span>
<Icon item={PERSONAL_ACCOUNT_SLUG} />
</CommandItem>
</CommandGroup>
<CommandSeparator />
<If condition={accounts.length > 0}>
<CommandGroup
heading={
<Trans
i18nKey={'teams:yourTeams'}
values={{ teamsCount: accounts.length }}
/>
}
>
{(accounts ?? []).map((account) => (
<CommandItem
data-test={'account-selector-team'}
data-name={account.label}
data-slug={account.value}
className={cn(
'group my-1 flex justify-between transition-colors',
{
['bg-muted']: value === account.value,
},
)}
key={account.value}
value={account.value ?? ''}
onSelect={(currentValue) => {
setOpen(false);
if (onAccountChange) {
onAccountChange(currentValue);
}
}}
>
<div className={'flex items-center'}>
<Avatar className={'rounded-xs mr-2 h-6 w-6'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={cn('rounded-xs', {
['bg-background']: value === account.value,
['group-hover:bg-background']:
value !== account.value,
})}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span className={'mr-2 max-w-[165px] truncate'}>
{account.label}
</span>
</div>
<Icon item={account.value ?? ''} />
</CommandItem>
))}
</CommandGroup>
</If>
</CommandList>
</Command>
<Separator />
<If condition={features.enableTeamCreation}>
<div className={'p-1'}>
<Button
data-test={'create-team-account-trigger'}
variant="ghost"
size={'sm'}
className="w-full justify-start text-sm font-normal"
onClick={() => {
setIsCreatingAccount(true);
setOpen(false);
}}
>
<Plus className="mr-3 h-4 w-4" />
<span>
<Trans i18nKey={'teams:createTeam'} />
</span>
</Button>
</div>
</If>
</PopoverContent>
</Popover>
<If condition={features.enableTeamCreation}>
<CreateTeamAccountDialog
isOpen={isCreatingAccount}
setIsOpen={setIsCreatingAccount}
/>
</If>
</>
);
}
function UserAvatar(props: { pictureUrl?: string }) {
return (
<Avatar className={'rounded-xs h-6 w-6'}>
<AvatarImage src={props.pictureUrl} />
</Avatar>
);
}

View File

@@ -0,0 +1 @@
export * from './user-workspace-context';

View File

@@ -0,0 +1,225 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import type { User } from '@supabase/supabase-js';
import {
ChevronsUpDown,
Home,
LogOut,
MessageCircleQuestion,
Shield,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { SubMenuModeToggle } from '@kit/ui/mode-toggle';
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,
user,
signOutRequested,
showProfileName = true,
paths,
features,
account,
}: {
user: User;
account?: {
id: string | null;
name: string | null;
picture_url: string | null;
};
signOutRequested: () => unknown;
paths: {
home: string;
};
features: {
enableThemeToggle: boolean;
};
showProfileName?: boolean;
className?: string;
}) {
const { data: personalAccountData } = usePersonalAccountData(
user.id,
account,
);
const signedInAsLabel = useMemo(() => {
const email = user?.email ?? undefined;
const phone = user?.phone ?? undefined;
return email ?? phone;
}, [user]);
const displayName =
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
const isSuperAdmin = useMemo(() => {
const factors = user?.factors ?? [];
const hasAdminRole = user?.app_metadata.role === 'super-admin';
const hasTotpFactor = factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
return hasAdminRole && hasTotpFactor;
}, [user]);
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open your profile menu"
data-test={'account-dropdown-trigger'}
className={cn(
'animate-in fade-in focus:outline-primary flex cursor-pointer items-center duration-500 group-data-[minimized=true]:px-0',
className ?? '',
{
['active:bg-secondary/50 items-center gap-4 rounded-md' +
' hover:bg-secondary p-2 transition-colors']: showProfileName,
},
)}
>
<ProfileAvatar
className={'rounded-md'}
fallbackClassName={'rounded-md border'}
displayName={displayName ?? user?.email ?? ''}
pictureUrl={personalAccountData?.picture_url}
/>
<If condition={showProfileName}>
<div
className={
'fade-in animate-in flex w-full flex-col truncate text-left group-data-[minimized=true]:hidden'
}
>
<span
data-test={'account-dropdown-display-name'}
className={'truncate text-sm'}
>
{displayName}
</span>
<span
data-test={'account-dropdown-email'}
className={'text-muted-foreground truncate text-xs'}
>
{signedInAsLabel}
</span>
</div>
<ChevronsUpDown
className={
'text-muted-foreground mr-1 h-8 group-data-[minimized=true]:hidden'
}
/>
</If>
</DropdownMenuTrigger>
<DropdownMenuContent className={'xl:min-w-[15rem]!'}>
<DropdownMenuItem className={'h-10! rounded-none'}>
<div
className={'flex flex-col justify-start truncate text-left text-xs'}
>
<div className={'text-muted-foreground'}>
<Trans i18nKey={'common:signedInAs'} />
</div>
<div>
<span className={'block truncate'}>{signedInAsLabel}</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={paths.home}
>
<Home className={'h-5'} />
<span>
<Trans i18nKey={'common:routes.home'} />
</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'}
>
<MessageCircleQuestion className={'h-5'} />
<span>
<Trans i18nKey={'common:documentation'} />
</span>
</Link>
</DropdownMenuItem>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
<DropdownMenuSeparator />
<If condition={features.enableThemeToggle}>
<SubMenuModeToggle />
</If>
<DropdownMenuSeparator />
<DropdownMenuItem
data-test={'account-dropdown-sign-out'}
role={'button'}
className={'cursor-pointer'}
onClick={signOutRequested}
>
<span className={'flex w-full items-center space-x-2'}>
<LogOut className={'h-5'} />
<span>
<Trans i18nKey={'auth:signOut'} />
</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm, useWatch } from 'react-hook-form';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form } from '@kit/ui/form';
import { Trans } from '@kit/ui/trans';
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
import { deletePersonalAccountAction } from '../../server/personal-accounts-server-actions';
export function AccountDangerZone() {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'account:deleteAccount'} />
</span>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account:deleteAccountDescription'} />
</p>
</div>
<div>
<DeleteAccountModal />
</div>
</div>
);
}
function DeleteAccountModal() {
const { data: user } = useUser();
if (!user?.email) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account:deleteAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:deleteAccount'} />
</AlertDialogTitle>
</AlertDialogHeader>
<ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
<DeleteAccountForm email={user.email} />
</ErrorBoundary>
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteAccountForm(props: { email: string }) {
const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: {
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
if (!otp) {
return (
<VerifyOtpForm
purpose={'delete-personal-account'}
email={props.email}
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
);
}
return (
<Form {...form}>
<form
data-test={'delete-account-form'}
action={deletePersonalAccountAction}
className={'flex flex-col space-y-4'}
>
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}>
<div
className={
'border-destructive text-destructive rounded-md border p-4 text-sm'
}
>
<div className={'flex flex-col space-y-2'}>
<div>
<Trans i18nKey={'account:deleteAccountDescription'} />
</div>
<div>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</div>
</div>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
</AlertDialogFooter>
</form>
</Form>
);
}
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={pending || props.disabled}
name={'action'}
variant={'destructive'}
>
{pending ? (
<Trans i18nKey={'account:deletingAccount'} />
) : (
<Trans i18nKey={'account:deleteAccount'} />
)}
</Button>
);
}
function DeleteAccountErrorContainer() {
return (
<div className="flex flex-col gap-y-4">
<DeleteAccountErrorAlert />
<div>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</div>
</div>
);
}
function DeleteAccountErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:deleteAccountErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { LanguageSelector } from '@kit/ui/language-selector';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { usePersonalAccountData } from '../../hooks/use-personal-account-data';
import { AccountDangerZone } from './account-danger-zone';
import { UpdateEmailFormContainer } from './email/update-email-form-container';
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
import { UpdatePasswordFormContainer } from './password/update-password-container';
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
import { UpdateAccountImageContainer } from './update-account-image-container';
export function PersonalAccountSettingsContainer(
props: React.PropsWithChildren<{
userId: string;
features: {
enableAccountDeletion: boolean;
enablePasswordUpdate: boolean;
};
paths: {
callback: string;
};
}>,
) {
const supportsLanguageSelection = useSupportMultiLanguage();
const user = usePersonalAccountData(props.userId);
if (!user.data || user.isPending) {
return <LoadingOverlay fullPage />;
}
return (
<div className={'flex w-full flex-col space-y-4 pb-32'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:accountImage'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:accountImageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateAccountImageContainer
user={{
pictureUrl: user.data.picture_url,
id: user.data.id,
}}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:name'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:nameDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateAccountDetailsFormContainer user={user.data} />
</CardContent>
</Card>
<If condition={supportsLanguageSelection}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:language'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:languageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LanguageSelector />
</CardContent>
</Card>
</If>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updateEmailCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updateEmailCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateEmailFormContainer callbackPath={props.paths.callback} />
</CardContent>
</Card>
<If condition={props.features.enablePasswordUpdate}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updatePasswordCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updatePasswordCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdatePasswordFormContainer callbackPath={props.paths.callback} />
</CardContent>
</Card>
</If>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:multiFactorAuth'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<MultiFactorAuthFactorsList userId={props.userId} />
</CardContent>
</Card>
<If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:dangerZoneDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<AccountDangerZone />
</CardContent>
</Card>
</If>
</div>
);
}
function useSupportMultiLanguage() {
const { i18n } = useTranslation();
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
return supportedLangs.length > 1;
}

View File

@@ -0,0 +1,20 @@
'use client';
import { useUser } from '@kit/supabase/hooks/use-user';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { UpdateEmailForm } from './update-email-form';
export function UpdateEmailFormContainer(props: { callbackPath: string }) {
const { data: user, isPending } = useUser();
if (isPending) {
return <LoadingOverlay fullPage={false} />;
}
if (!user) {
return null;
}
return <UpdateEmailForm callbackPath={props.callbackPath} user={user} />;
}

View File

@@ -0,0 +1,150 @@
'use client';
import type { User } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
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';
import { UpdateEmailSchema } from '../../../schema/update-email.schema';
function createEmailResolver(currentEmail: string, errorMessage: string) {
return zodResolver(
UpdateEmailSchema.withTranslation(errorMessage).refine((schema) => {
return schema.email !== currentEmail;
}),
);
}
export function UpdateEmailForm({
user,
callbackPath,
}: {
user: User;
callbackPath: string;
}) {
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const updateEmail = ({ email }: { email: string }) => {
// then, we update the user's email address
const promise = async () => {
const redirectTo = new URL(
callbackPath,
window.location.origin,
).toString();
await updateUserMutation.mutateAsync({ email, redirectTo });
};
toast.promise(promise, {
success: t(`updateEmailSuccess`),
loading: t(`updateEmailLoading`),
error: t(`updateEmailError`),
});
};
const currentEmail = user.email;
const form = useForm({
resolver: createEmailResolver(currentEmail!, t('emailNotMatching')),
defaultValues: {
email: '',
repeatEmail: '',
},
});
return (
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
data-test={'account-email-form'}
onSubmit={form.handleSubmit(updateEmail)}
>
<If condition={updateUserMutation.data}>
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updateEmailSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updateEmailSuccessMessage'} />
</AlertDescription>
</Alert>
</If>
<div className={'flex flex-col space-y-4'}>
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:newEmail'} />
</FormLabel>
<FormControl>
<Input
data-test={'account-email-form-email-input'}
required
type={'email'}
placeholder={''}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
name={'email'}
/>
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:repeatEmail'} />
</FormLabel>
<FormControl>
<Input
{...field}
data-test={'account-email-form-repeat-email-input'}
required
type={'email'}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
name={'repeatEmail'}
/>
<div>
<Button disabled={updateUserMutation.isPending}>
<Trans i18nKey={'account:updateEmailSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>
);
}

View File

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

View File

@@ -0,0 +1,308 @@
'use client';
import { useCallback, useState } from 'react';
import type { Factor } from '@supabase/supabase-js';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Spinner } from '@kit/ui/spinner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog';
export function MultiFactorAuthFactorsList(props: { userId: string }) {
return (
<div className={'flex flex-col space-y-4'}>
<FactorsTableContainer userId={props.userId} />
<div>
<MultiFactorAuthSetupDialog userId={props.userId} />
</div>
</div>
);
}
function FactorsTableContainer(props: { userId: string }) {
const {
data: factors,
isLoading,
isError,
} = useFetchAuthFactors(props.userId);
if (isLoading) {
return (
<div className={'flex items-center space-x-4'}>
<Spinner />
<div>
<Trans i18nKey={'account:loadingFactors'} />
</div>
</div>
);
}
if (isError) {
return (
<div>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:factorsListError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:factorsListErrorDescription'} />
</AlertDescription>
</Alert>
</div>
);
}
const allFactors = factors?.all ?? [];
if (!allFactors.length) {
return (
<div className={'flex flex-col space-y-4'}>
<Alert>
<ShieldCheck className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:multiFactorAuthHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</AlertDescription>
</Alert>
</div>
);
}
return <FactorsTable factors={allFactors} userId={props.userId} />;
}
function ConfirmUnenrollFactorModal(
props: React.PropsWithChildren<{
factorId: string;
userId: string;
setIsModalOpen: (isOpen: boolean) => void;
}>,
) {
const { t } = useTranslation();
const unEnroll = useUnenrollFactor(props.userId);
const onUnenrollRequested = useCallback(
(factorId: string) => {
if (unEnroll.isPending) return;
const promise = unEnroll.mutateAsync(factorId).then((response) => {
props.setIsModalOpen(false);
if (!response.success) {
const errorCode = response.data;
throw t(`auth:errors.${errorCode}`, {
defaultValue: t(`account:unenrollFactorError`),
});
}
});
toast.promise(promise, {
loading: t(`account:unenrollingFactor`),
success: t(`account:unenrollFactorSuccess`),
error: (error: string) => {
return error;
},
});
},
[props, t, unEnroll],
);
return (
<AlertDialog open={!!props.factorId} onOpenChange={props.setIsModalOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:unenrollFactorModalHeading'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'account:unenrollFactorModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
type={'button'}
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function FactorsTable({
factors,
userId,
}: React.PropsWithChildren<{
factors: Factor[];
userId: string;
}>) {
const [unEnrolling, setUnenrolling] = useState<string>();
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Trans i18nKey={'account:factorName'} />
</TableHead>
<TableHead>
<Trans i18nKey={'account:factorType'} />
</TableHead>
<TableHead>
<Trans i18nKey={'account:factorStatus'} />
</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{factors.map((factor) => (
<TableRow key={factor.id}>
<TableCell>
<span className={'block truncate'}>{factor.friendly_name}</span>
</TableCell>
<TableCell>
<Badge variant={'info'} className={'inline-flex uppercase'}>
{factor.factor_type}
</Badge>
</TableCell>
<td>
<Badge
className={'inline-flex capitalize'}
variant={factor.status === 'verified' ? 'success' : 'outline'}
>
{factor.status}
</Badge>
</td>
<td className={'flex justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey={'account:unenrollTooltip'} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
</TableRow>
))}
</TableBody>
</Table>
<If condition={unEnrolling}>
{(factorId) => (
<ConfirmUnenrollFactorModal
userId={userId}
factorId={factorId}
setIsModalOpen={() => setUnenrolling(undefined)}
/>
)}
</If>
</>
);
}
function useUnenrollFactor(userId: string) {
const queryClient = useQueryClient();
const client = useSupabase();
const mutationKey = useFactorsMutationKey(userId);
const mutationFn = async (factorId: string) => {
const { data, error } = await client.auth.mfa.unenroll({
factorId,
});
if (error) {
return {
success: false as const,
data: error.code as string,
};
}
return {
success: true as const,
data,
};
};
return useMutation({
mutationFn,
mutationKey,
onSuccess: () => {
return queryClient.refetchQueries({
queryKey: mutationKey,
});
},
});
}

View File

@@ -0,0 +1,513 @@
'use client';
import { useCallback, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { z } from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import { Trans } from '@kit/ui/trans';
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
export function MultiFactorAuthSetupDialog(props: { userId: string }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const onEnrollSuccess = useCallback(() => {
setIsOpen(false);
return toast.success(t(`account:multiFactorSetupSuccess`));
}, [t]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</Button>
</DialogTrigger>
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</DialogDescription>
</DialogHeader>
<div>
<MultiFactorAuthSetupForm
userId={props.userId}
onCancel={() => setIsOpen(false)}
onEnrolled={onEnrollSuccess}
/>
</div>
</DialogContent>
</Dialog>
);
}
function MultiFactorAuthSetupForm({
onEnrolled,
onCancel,
userId,
}: React.PropsWithChildren<{
userId: string;
onCancel: () => void;
onEnrolled: () => void;
}>) {
const verifyCodeMutation = useVerifyCodeMutation(userId);
const verificationCodeForm = useForm({
resolver: zodResolver(
z.object({
factorId: z.string().min(1),
verificationCode: z.string().min(6).max(6),
}),
),
defaultValues: {
factorId: '',
verificationCode: '',
},
});
const [state, setState] = useState({
loading: false,
error: '',
});
const factorId = useWatch({
name: 'factorId',
control: verificationCodeForm.control,
});
const onSubmit = useCallback(
async ({
verificationCode,
factorId,
}: {
verificationCode: string;
factorId: string;
}) => {
setState({
loading: true,
error: '',
});
try {
await verifyCodeMutation.mutateAsync({
factorId,
code: verificationCode,
});
await refreshAuthSession();
setState({
loading: false,
error: '',
});
onEnrolled();
} catch (error) {
const message = (error as Error).message || `Unknown error`;
setState({
loading: false,
error: message,
});
}
},
[onEnrolled, verifyCodeMutation],
);
if (state.error) {
return <ErrorAlert />;
}
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex justify-center'}>
<FactorQrCode
userId={userId}
onCancel={onCancel}
onSetFactorId={(factorId) =>
verificationCodeForm.setValue('factorId', factorId)
}
/>
</div>
<If condition={factorId}>
<Form {...verificationCodeForm}>
<form
onSubmit={verificationCodeForm.handleSubmit(onSubmit)}
className={'w-full'}
>
<div className={'flex flex-col space-y-8'}>
<FormField
render={({ field }) => {
return (
<FormItem
className={
'mx-auto flex flex-col items-center justify-center'
}
>
<FormControl>
<InputOTP {...field} maxLength={6} minLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
<Trans
i18nKey={'account:verifyActivationCodeDescription'}
/>
</FormDescription>
<FormMessage />
</FormItem>
);
}}
name={'verificationCode'}
/>
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button
disabled={
!verificationCodeForm.formState.isValid || state.loading
}
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
)}
</Button>
</div>
</div>
</form>
</Form>
</If>
</div>
);
}
function FactorQrCode({
onSetFactorId,
onCancel,
userId,
}: React.PropsWithChildren<{
userId: string;
onCancel: () => void;
onSetFactorId: (factorId: string) => void;
}>) {
const enrollFactorMutation = useEnrollFactor(userId);
const { t } = useTranslation();
const [error, setError] = useState<string>('');
const form = useForm({
resolver: zodResolver(
z.object({
factorName: z.string().min(1),
qrCode: z.string().min(1),
}),
),
defaultValues: {
factorName: '',
qrCode: '',
},
});
const factorName = useWatch({ name: 'factorName', control: form.control });
if (error) {
return (
<div className={'flex w-full flex-col space-y-2'}>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:qrCodeErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={`auth:errors.${error}`}
defaults={t('account:qrCodeErrorDescription')}
/>
</AlertDescription>
</Alert>
<div>
<Button variant={'outline'} onClick={onCancel}>
<ArrowLeftIcon className={'h-4'} />
<Trans i18nKey={`common:retry`} />
</Button>
</div>
</div>
);
}
if (!factorName) {
return (
<FactorNameForm
onCancel={onCancel}
onSetFactorName={async (name) => {
const response = await enrollFactorMutation.mutateAsync(name);
if (!response.success) {
return setError(response.data as string);
}
const data = response.data;
if (data.type === 'totp') {
form.setValue('factorName', name);
form.setValue('qrCode', data.totp.qr_code);
}
// dispatch event to set factor ID
onSetFactorId(data.id);
}}
/>
);
}
return (
<div
className={
'dark:bg-secondary flex flex-col space-y-4 rounded-lg border p-4'
}
>
<p>
<span className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account:multiFactorModalHeading'} />
</span>
</p>
<div className={'flex justify-center'}>
<QrImage src={form.getValues('qrCode')} />
</div>
</div>
);
}
function FactorNameForm(
props: React.PropsWithChildren<{
onSetFactorName: (name: string) => void;
onCancel: () => void;
}>,
) {
const form = useForm({
resolver: zodResolver(
z.object({
name: z.string().min(1),
}),
),
defaultValues: {
name: '',
},
});
return (
<Form {...form}>
<form
className={'w-full'}
onSubmit={form.handleSubmit((data) => {
props.onSetFactorName(data.name);
})}
>
<div className={'flex flex-col space-y-4'}>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:factorNameLabel'} />
</FormLabel>
<FormControl>
<Input autoComplete={'off'} required {...field} />
</FormControl>
<FormDescription>
<Trans i18nKey={'account:factorNameHint'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>
);
}
function QrImage({ src }: { src: string }) {
return (
<img
alt={'QR Code'}
src={src}
width={160}
height={160}
className={'bg-white p-2'}
/>
);
}
function useEnrollFactor(userId: string) {
const client = useSupabase();
const queryClient = useQueryClient();
const mutationKey = useFactorsMutationKey(userId);
const mutationFn = async (factorName: string) => {
const response = await client.auth.mfa.enroll({
friendlyName: factorName,
factorType: 'totp',
});
if (response.error) {
return {
success: false as const,
data: response.error.code,
};
}
return {
success: true as const,
data: response.data,
};
};
return useMutation({
mutationFn,
mutationKey,
onSuccess() {
return queryClient.refetchQueries({
queryKey: mutationKey,
});
},
});
}
function useVerifyCodeMutation(userId: string) {
const mutationKey = useFactorsMutationKey(userId);
const client = useSupabase();
const queryClient = useQueryClient();
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,
onSuccess: () => {
return queryClient.refetchQueries({ queryKey: mutationKey });
},
});
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert } from '@kit/ui/alert';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { UpdatePasswordForm } from './update-password-form';
export function UpdatePasswordFormContainer(
props: React.PropsWithChildren<{
callbackPath: string;
}>,
) {
const { data: user, isPending } = useUser();
if (isPending) {
return <LoadingOverlay fullPage={false} />;
}
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={'account:cannotUpdatePassword'} />
</Alert>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
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,
FormDescription,
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';
import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
export const UpdatePasswordForm = ({
user,
callbackPath,
}: {
user: User;
callbackPath: string;
}) => {
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
const updatePasswordFromCredential = (password: string) => {
const redirectTo = [window.location.origin, callbackPath].join('');
const promise = updateUserMutation
.mutateAsync({ password, redirectTo })
.catch((error) => {
if (
typeof error === 'string' &&
error?.includes('Password update requires reauthentication')
) {
setNeedsReauthentication(true);
} else {
throw error;
}
});
toast.promise(() => promise, {
success: t(`updatePasswordSuccess`),
error: t(`updatePasswordError`),
loading: t(`updatePasswordLoading`),
});
};
const updatePasswordCallback = 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(`cannotUpdatePassword`));
}
updatePasswordFromCredential(newPassword);
};
const form = useForm({
resolver: zodResolver(
PasswordUpdateSchema.withTranslation(t('passwordNotMatching')),
),
defaultValues: {
newPassword: '',
repeatPassword: '',
},
});
return (
<Form {...form}>
<form
data-test={'account-password-form'}
onSubmit={form.handleSubmit(updatePasswordCallback)}
>
<div className={'flex flex-col space-y-4'}>
<If condition={updateUserMutation.data}>
<SuccessAlert />
</If>
<If condition={needsReauthentication}>
<NeedsReauthenticationAlert />
</If>
<FormField
name={'newPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:newPassword'} />
</Label>
</FormLabel>
<FormControl>
<Input
data-test={'account-password-form-password-input'}
autoComplete={'new-password'}
required
type={'password'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'repeatPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:repeatPassword'} />
</Label>
</FormLabel>
<FormControl>
<Input
data-test={'account-password-form-repeat-password-input'}
required
type={'password'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<div>
<Button disabled={updateUserMutation.isPending}>
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>
);
};
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
);
}
function NeedsReauthenticationAlert() {
return (
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
import { UpdateAccountDetailsForm } from './update-account-details-form';
export function UpdateAccountDetailsFormContainer({
user,
}: {
user: {
name: string | null;
id: string;
};
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
return (
<UpdateAccountDetailsForm
displayName={user.name ?? ''}
userId={user.id}
onUpdate={() => revalidateUserDataQuery(user.id)}
/>
);
}

View File

@@ -0,0 +1,97 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
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';
import { AccountDetailsSchema } from '../../schema/account-details.schema';
type UpdateUserDataParams = Database['public']['Tables']['accounts']['Update'];
export function UpdateAccountDetailsForm({
displayName,
onUpdate,
userId,
}: {
displayName: string;
userId: string;
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
}) {
const updateAccountMutation = useUpdateAccountData(userId);
const { t } = useTranslation('account');
const form = useForm({
resolver: zodResolver(AccountDetailsSchema),
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(`updateProfileSuccess`),
error: t(`updateProfileError`),
loading: t(`updateProfileLoading`),
});
};
return (
<div className={'flex flex-col space-y-8'}>
<Form {...form}>
<form
data-test={'update-account-name-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name={'displayName'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:name'} />
</FormLabel>
<FormControl>
<Input
data-test={'account-display-name'}
minLength={2}
placeholder={''}
maxLength={100}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader';
import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
const AVATARS_BUCKET = 'account_image';
export function UpdateAccountImageContainer({
user,
}: {
user: {
pictureUrl: string | null;
id: string;
};
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
return (
<UploadProfileAvatarForm
pictureUrl={user.pictureUrl ?? null}
userId={user.id}
onAvatarUpdated={() => revalidateUserDataQuery(user.id)}
/>
);
}
function UploadProfileAvatarForm(props: {
pictureUrl: string | null;
userId: string;
onAvatarUpdated: () => void;
}) {
const client = useSupabase();
const { t } = useTranslation('account');
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.pictureUrl) {
return (
deleteProfilePhoto(client, props.pictureUrl) ?? 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.pictureUrl} onValueChange={onValueChange}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account:profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account:profilePictureSubheading'} />
</span>
</div>
</ImageUploader>
);
}
function deleteProfilePhoto(client: SupabaseClient<Database>, 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<Database>,
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);
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');
// we add a version to the URL to ensure
// the browser always fetches the latest image
const uniqueId = nanoid(16);
return `${userId}.${extension}?v=${uniqueId}`;
}

View File

@@ -0,0 +1,40 @@
'use client';
import { createContext } from 'react';
import { User } from '@supabase/supabase-js';
import { Tables } from '@kit/supabase/database';
interface UserWorkspace {
accounts: Array<{
label: string | null;
value: string | null;
image: string | null;
}>;
workspace: {
id: string | null;
name: string | null;
picture_url: string | null;
subscription_status: Tables<'subscriptions'>['status'] | null;
};
user: User;
}
export const UserWorkspaceContext = createContext<UserWorkspace>(
{} as UserWorkspace,
);
export function UserWorkspaceContextProvider(
props: React.PropsWithChildren<{
value: UserWorkspace;
}>,
) {
return (
<UserWorkspaceContext.Provider value={props.value}>
{props.children}
</UserWorkspaceContext.Provider>
);
}

View File

@@ -0,0 +1,69 @@
import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function usePersonalAccountData(
userId: string,
partialAccount?: {
id: string | null;
name: string | null;
picture_url: string | null;
},
) {
const client = useSupabase();
const queryKey = ['account:data', userId];
const queryFn = async () => {
if (!userId) {
return null;
}
const response = await client
.from('accounts')
.select(
`
id,
name,
picture_url
`,
)
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', true)
.single();
if (response.error) {
throw response.error;
}
return response.data;
};
return useQuery({
queryKey,
queryFn,
enabled: !!userId,
refetchOnWindowFocus: false,
refetchOnMount: false,
initialData: partialAccount?.id
? {
id: partialAccount.id,
name: partialAccount.name,
picture_url: partialAccount.picture_url,
}
: undefined,
});
}
export function useRevalidatePersonalAccountDataQuery() {
const queryClient = useQueryClient();
return useCallback(
(userId: string) =>
queryClient.invalidateQueries({
queryKey: ['account:data', userId],
}),
[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,17 @@
'use client';
import { useContext } from 'react';
import { UserWorkspaceContext } from '../components';
export function useUserWorkspace() {
const ctx = useContext(UserWorkspaceContext);
if (!ctx) {
throw new Error(
'useUserWorkspace must be used within a UserWorkspaceProvider. This is only provided within the user workspace /home',
);
}
return ctx;
}

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
export const UpdateEmailSchema = {
withTranslation: (errorMessage: string) => {
return z
.object({
email: z.string().email(),
repeatEmail: z.string().email(),
})
.refine(
(values) => {
return values.email === values.repeatEmail;
},
{
path: ['repeatEmail'],
message: errorMessage,
},
);
},
};

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
export const PasswordUpdateSchema = {
withTranslation: (errorMessage: string) => {
return z
.object({
newPassword: z.string().min(8).max(99),
repeatPassword: z.string().min(8).max(99),
})
.refine(
(values) => {
return values.newPassword === values.repeatPassword;
},
{
path: ['repeatPassword'],
message: errorMessage,
},
);
},
};

View File

@@ -0,0 +1,131 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* Class representing an API for interacting with user accounts.
* @constructor
* @param {SupabaseClient<Database>} client - The Supabase client instance.
*/
class AccountsApi {
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name getAccount
* @description Get the account data for the given ID.
* @param id
*/
async getAccount(id: string) {
const { data, error } = await this.client
.from('accounts')
.select('*')
.eq('id', id)
.single();
if (error) {
throw error;
}
return data;
}
/**
* @name getAccountWorkspace
* @description Get the account workspace data.
*/
async getAccountWorkspace() {
const { data, error } = await this.client
.from('user_account_workspace')
.select(`*`)
.single();
if (error) {
throw error;
}
return data;
}
/**
* @name loadUserAccounts
* Load the user accounts.
*/
async loadUserAccounts() {
const { data: accounts, error } = await this.client
.from('user_accounts')
.select(`name, slug, picture_url`);
if (error) {
throw error;
}
return accounts.map(({ name, slug, picture_url }) => {
return {
label: name,
value: slug,
image: picture_url,
};
});
}
/**
* @name getSubscription
* Get the subscription data for the given user.
* @param accountId
*/
async getSubscription(accountId: string) {
const response = await this.client
.from('subscriptions')
.select('*, items: subscription_items !inner (*)')
.eq('account_id', accountId)
.maybeSingle();
if (response.error) {
throw response.error;
}
return response.data;
}
/**
* Get the orders data for the given account.
* @param accountId
*/
async getOrder(accountId: string) {
const response = await this.client
.from('orders')
.select('*, items: order_items !inner (*)')
.eq('account_id', accountId)
.maybeSingle();
if (response.error) {
throw response.error;
}
return response.data;
}
/**
* @name getCustomerId
* Get the billing customer ID for the given user.
* If the user does not have a billing customer ID, it will return null.
* @param accountId
*/
async getCustomerId(accountId: string) {
const response = await this.client
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)
.maybeSingle();
if (response.error) {
throw response.error;
}
return response.data?.customer_id;
}
}
export function createAccountsApi(client: SupabaseClient<Database>) {
return new AccountsApi(client);
}

View File

@@ -0,0 +1,104 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { DeletePersonalAccountSchema } from '../schema/delete-personal-account.schema';
import { createDeletePersonalAccountService } from './services/delete-personal-account.service';
const enableAccountDeletion =
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION === 'true';
export async function refreshAuthSession() {
const client = getSupabaseServerClient();
await client.auth.refreshSession();
return {};
}
export const deletePersonalAccountAction = enhanceAction(
async (formData: FormData, user) => {
const logger = await getLogger();
// validate the form data
const { success } = DeletePersonalAccountSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!success) {
throw new Error('Invalid form data');
}
const ctx = {
name: 'account.delete',
userId: user.id,
};
const otp = formData.get('otp') as string;
if (!otp) {
throw new Error('OTP is required');
}
if (!enableAccountDeletion) {
logger.warn(ctx, `Account deletion is not enabled`);
throw new Error('Account deletion is not enabled');
}
logger.info(ctx, `Deleting account...`);
// verify the OTP
const client = getSupabaseServerClient();
const otpApi = createOtpApi(client);
const otpResult = await otpApi.verifyToken({
token: otp,
userId: user.id,
purpose: 'delete-personal-account',
});
if (!otpResult.valid) {
throw new Error('Invalid OTP');
}
// validate the user ID matches the nonce's user ID
if (otpResult.user_id !== user.id) {
logger.error(
ctx,
`This token was meant to be used by a different user. Exiting.`,
);
throw new Error('Nonce mismatch');
}
// create a new instance of the personal accounts service
const service = createDeletePersonalAccountService();
// delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({
adminClient: getSupabaseServerAdminClient(),
userId: user.id,
userEmail: user.email ?? null,
});
// sign out the user after deleting their account
await client.auth.signOut();
logger.info(ctx, `Account request successfully sent`);
// clear the cache for all pages
revalidatePath('/', 'layout');
// redirect to the home page
redirect('/');
},
{},
);

View File

@@ -0,0 +1,68 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
export function createDeletePersonalAccountService() {
return new DeletePersonalAccountService();
}
/**
* @name DeletePersonalAccountService
* @description Service for managing accounts in the application
* @param Database - The Supabase database type to use
* @example
* const client = getSupabaseClient();
* const accountsService = new DeletePersonalAccountService();
*/
class DeletePersonalAccountService {
private namespace = 'accounts.delete';
/**
* @name deletePersonalAccount
* Delete personal account of a user.
* This will delete the user from the authentication provider and cancel all subscriptions.
*
* Permissions are not checked here, as they are checked in the server action.
* USE WITH CAUTION. THE USER MUST HAVE THE NECESSARY PERMISSIONS.
*/
async deletePersonalAccount(params: {
adminClient: SupabaseClient<Database>;
userId: string;
userEmail: string | null;
}) {
const logger = await getLogger();
const userId = params.userId;
const ctx = { userId, name: this.namespace };
logger.info(
ctx,
'User requested to delete their personal account. Processing...',
);
// execute the deletion of the user
try {
await params.adminClient.auth.admin.deleteUser(userId);
logger.info(ctx, 'User successfully deleted!');
return {
success: true,
};
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Encountered an error deleting user',
);
throw new Error('Error deleting user');
}
}
}

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