B2B-88: add starter kit structure and elements
This commit is contained in:
3
packages/features/accounts/eslint.config.mjs
Normal file
3
packages/features/accounts/eslint.config.mjs
Normal 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
17
packages/features/accounts/node_modules/.bin/nanoid
generated
vendored
Executable 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
17
packages/features/accounts/node_modules/.bin/next
generated
vendored
Executable 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
17
packages/features/accounts/node_modules/.bin/tsc
generated
vendored
Executable 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
17
packages/features/accounts/node_modules/.bin/tsserver
generated
vendored
Executable 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
|
||||
1
packages/features/accounts/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
@@ -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
|
||||
1
packages/features/accounts/node_modules/@kit/billing-gateway
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/billing-gateway
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../billing/gateway
|
||||
1
packages/features/accounts/node_modules/@kit/email-templates
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/email-templates
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../email-templates
|
||||
1
packages/features/accounts/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/features/accounts/node_modules/@kit/mailers
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/mailers
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../mailers/core
|
||||
1
packages/features/accounts/node_modules/@kit/monitoring
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/monitoring
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../monitoring/api
|
||||
1
packages/features/accounts/node_modules/@kit/next
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../next
|
||||
1
packages/features/accounts/node_modules/@kit/otp
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/otp
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../otp
|
||||
1
packages/features/accounts/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/features/accounts/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../shared
|
||||
1
packages/features/accounts/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../supabase
|
||||
1
packages/features/accounts/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/features/accounts/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/features/accounts/node_modules/@radix-ui/react-icons
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@radix-ui/react-icons
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@19.1.0/node_modules/@radix-ui/react-icons
|
||||
1
packages/features/accounts/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js
|
||||
1
packages/features/accounts/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/features/accounts/node_modules/@types/react-dom
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/@types/react-dom
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/lucide-react
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/nanoid
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid
|
||||
1
packages/features/accounts/node_modules/next
generated
vendored
Symbolic link
1
packages/features/accounts/node_modules/next
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/next-themes
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/react
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/react-dom
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/react-hook-form
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/react-i18next
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/sonner
generated
vendored
Symbolic link
@@ -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
1
packages/features/accounts/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
59
packages/features/accounts/package.json
Normal file
59
packages/features/accounts/package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
293
packages/features/accounts/src/components/account-selector.tsx
Normal file
293
packages/features/accounts/src/components/account-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
packages/features/accounts/src/components/index.ts
Normal file
1
packages/features/accounts/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './user-workspace-context';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account-settings-container';
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
29
packages/features/accounts/src/hooks/use-update-account.ts
Normal file
29
packages/features/accounts/src/hooks/use-update-account.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
type UpdateData = Database['public']['Tables']['accounts']['Update'];
|
||||
|
||||
export function useUpdateAccountData(accountId: string) {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['account:data', accountId];
|
||||
|
||||
const mutationFn = async (data: UpdateData) => {
|
||||
const response = await client.from('accounts').update(data).match({
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
17
packages/features/accounts/src/hooks/use-user-workspace.ts
Normal file
17
packages/features/accounts/src/hooks/use-user-workspace.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AccountDetailsSchema = z.object({
|
||||
displayName: z.string().min(2).max(100),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DeletePersonalAccountSchema = z.object({
|
||||
otp: z.string().min(6),
|
||||
});
|
||||
20
packages/features/accounts/src/schema/update-email.schema.ts
Normal file
20
packages/features/accounts/src/schema/update-email.schema.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
131
packages/features/accounts/src/server/api.ts
Normal file
131
packages/features/accounts/src/server/api.ts
Normal 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);
|
||||
}
|
||||
@@ -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('/');
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/features/accounts/tsconfig.json
Normal file
8
packages/features/accounts/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user