Add OTP sign-in option + Account Linking (#276)
* feat(accounts): allow linking email password * feat(auth): add OTP sign-in * refactor(accounts): remove 'sonner' dependency and update toast imports * feat(supabase): enable analytics and configure database seeding * feat(auth): update email templates and add OTP template * feat(auth): add last sign in method hints * feat(config): add devIndicators position to bottom-right * feat(auth): implement comprehensive last authentication method tracking tests
This commit is contained in:
committed by
GitHub
parent
856e9612c4
commit
9033155fcd
@@ -16,7 +16,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.30"
|
||||
"@types/node": "^24.0.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"./marketing": "./src/components/marketing.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/lemon-squeezy": "workspace:*",
|
||||
@@ -27,14 +27,14 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"zod": "^3.25.56"
|
||||
"react-i18next": "^15.5.3",
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/wordpress": "workspace:*",
|
||||
"@types/node": "^22.15.30"
|
||||
"@types/node": "^24.0.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "19.1.8",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "19.1.8",
|
||||
"wp-types": "^4.68.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@kit/team-accounts": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.41"
|
||||
"@react-email/components": "0.0.42"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
@@ -35,18 +35,17 @@
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@types/react": "19.1.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"sonner": "^2.0.5",
|
||||
"zod": "^3.25.56"
|
||||
"react-i18next": "^15.5.3",
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"typesVersions": {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function PersonalAccountDropdown({
|
||||
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',
|
||||
'animate-in group/trigger fade-in focus:outline-primary flex cursor-pointer items-center border border-dashed group-data-[minimized=true]:px-0',
|
||||
className ?? '',
|
||||
{
|
||||
['active:bg-secondary/50 items-center gap-4 rounded-md' +
|
||||
@@ -100,7 +100,9 @@ export function PersonalAccountDropdown({
|
||||
)}
|
||||
>
|
||||
<ProfileAvatar
|
||||
className={'rounded-md'}
|
||||
className={
|
||||
'group-hover/trigger:border-background/50 rounded-md border border-transparent transition-colors'
|
||||
}
|
||||
fallbackClassName={'rounded-md border'}
|
||||
displayName={displayName ?? user?.email ?? ''}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ 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 { LinkAccountsList } from './link-accounts';
|
||||
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
|
||||
import { UpdatePasswordFormContainer } from './password/update-password-container';
|
||||
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
|
||||
@@ -29,11 +32,14 @@ export function PersonalAccountSettingsContainer(
|
||||
features: {
|
||||
enableAccountDeletion: boolean;
|
||||
enablePasswordUpdate: boolean;
|
||||
enableAccountLinking: boolean;
|
||||
};
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
};
|
||||
|
||||
providers: Provider[];
|
||||
}>,
|
||||
) {
|
||||
const supportsLanguageSelection = useSupportMultiLanguage();
|
||||
@@ -150,6 +156,24 @@ export function PersonalAccountSettingsContainer(
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={props.features.enableAccountLinking}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:linkedAccounts'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:linkedAccountsDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<LinkAccountsList providers={props.providers} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
|
||||
<If condition={props.features.enableAccountDeletion}>
|
||||
<Card className={'border-destructive'}>
|
||||
<CardHeader>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdateEmailSchema } from '../../../schema/update-email.schema';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './account-settings-container';
|
||||
export * from './link-accounts';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './link-accounts-list';
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import type { Provider, UserIdentity } from '@supabase/supabase-js';
|
||||
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
import { useLinkIdentityWithProvider } from '@kit/supabase/hooks/use-link-identity-with-provider';
|
||||
import { useUnlinkUserIdentity } from '@kit/supabase/hooks/use-unlink-user-identity';
|
||||
import { useUserIdentities } from '@kit/supabase/hooks/use-user-identities';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function LinkAccountsList(props: { providers: Provider[] }) {
|
||||
const unlinkMutation = useUnlinkUserIdentity();
|
||||
const linkMutation = useLinkIdentityWithProvider();
|
||||
|
||||
const {
|
||||
identities,
|
||||
hasMultipleIdentities,
|
||||
isProviderConnected,
|
||||
isLoading: isLoadingIdentities,
|
||||
} = useUserIdentities();
|
||||
|
||||
// Only show providers from the allowed list that aren't already connected
|
||||
const availableProviders = props.providers.filter(
|
||||
(provider) => !isProviderConnected(provider),
|
||||
);
|
||||
|
||||
// Show all connected identities, even if their provider isn't in the allowed providers list
|
||||
const connectedIdentities = identities;
|
||||
|
||||
const handleUnlinkAccount = (identity: UserIdentity) => {
|
||||
const promise = unlinkMutation.mutateAsync(identity);
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: <Trans i18nKey={'account:unlinkingAccount'} />,
|
||||
success: <Trans i18nKey={'account:accountUnlinked'} />,
|
||||
error: <Trans i18nKey={'account:unlinkAccountError'} />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLinkAccount = (provider: Provider) => {
|
||||
const promise = linkMutation.mutateAsync(provider);
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: <Trans i18nKey={'account:linkingAccount'} />,
|
||||
success: <Trans i18nKey={'account:accountLinked'} />,
|
||||
error: <Trans i18nKey={'account:linkAccountError'} />,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingIdentities) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Linked Accounts Section */}
|
||||
<If condition={connectedIdentities.length > 0}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-foreground text-sm font-medium">
|
||||
<Trans i18nKey={'account:linkedAccounts'} />
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans i18nKey={'account:alreadyLinkedAccountsDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{connectedIdentities.map((identity) => (
|
||||
<div
|
||||
key={identity.id}
|
||||
className="bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<OauthProviderLogoImage providerId={identity.provider} />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-x-2 text-sm font-medium capitalize">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
|
||||
<span>{identity.provider}</span>
|
||||
</span>
|
||||
|
||||
<If condition={identity.identity_data?.email}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{identity.identity_data?.email as string}
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={hasMultipleIdentities}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:confirmUnlinkAccount'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={'account:unlinkAccountConfirmation'}
|
||||
values={{ provider: identity.provider }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlinkAccount(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</If>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
{/* Available Accounts Section */}
|
||||
<If condition={availableProviders.length > 0}>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-foreground text-sm font-medium">
|
||||
<Trans i18nKey={'account:availableAccounts'} />
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans i18nKey={'account:availableAccountsDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{availableProviders.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
className="hover:bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3 transition-colors"
|
||||
onClick={() => handleLinkAccount(provider)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<OauthProviderLogoImage providerId={provider} />
|
||||
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{provider}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If
|
||||
condition={
|
||||
connectedIdentities.length === 0 && availableProviders.length === 0
|
||||
}
|
||||
>
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<Trans i18nKey={'account:noAccountsAvailable'} />
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@kit/ui/input-otp';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'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';
|
||||
|
||||
@@ -18,25 +16,9 @@ export function UpdatePasswordFormContainer(
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (!user?.email) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useUpdateAccountData } from '../../hooks/use-update-account';
|
||||
|
||||
@@ -5,11 +5,11 @@ 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 { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LinkEmailPasswordSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine((values) => values.password === values.repeatPassword, {
|
||||
path: ['repeatPassword'],
|
||||
message: `account:passwordNotMatching`,
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
@@ -21,15 +21,15 @@
|
||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.6",
|
||||
"lucide-react": "^0.513.0",
|
||||
"@types/react": "19.1.8",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
"./mfa": "./src/mfa.ts",
|
||||
"./captcha/client": "./src/captcha/client/index.ts",
|
||||
"./captcha/server": "./src/captcha/server/index.ts",
|
||||
"./resend-email-link": "./src/components/resend-auth-link-form.tsx"
|
||||
"./resend-email-link": "./src/components/resend-auth-link-form.tsx",
|
||||
"./oauth-provider-logo-image": "./src/components/oauth-provider-logo-image.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
@@ -29,14 +30,14 @@
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@types/react": "19.1.6",
|
||||
"lucide-react": "^0.513.0",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@types/react": "19.1.8",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-i18next": "^15.5.3",
|
||||
"sonner": "^2.0.5",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"typesVersions": {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function AuthLayoutShell({
|
||||
{Logo ? <Logo /> : null}
|
||||
|
||||
<div
|
||||
className={`bg-background flex w-full max-w-[23rem] flex-col gap-y-6 rounded-lg px-6 md:w-8/12 md:px-8 md:py-6 lg:w-5/12 lg:px-8 xl:w-4/12 xl:gap-y-8 xl:py-8`}
|
||||
className={`bg-background flex w-full max-w-[23rem] flex-col gap-y-6 rounded-lg px-6 md:w-8/12 md:px-8 md:py-6 lg:w-5/12 lg:px-8 xl:w-4/12 xl:py-8`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function AuthLinkRedirect(props: { redirectPath?: string }) {
|
||||
const params = useSearchParams();
|
||||
|
||||
const redirectPath = params?.get('redirectPath') ?? props.redirectPath ?? '/';
|
||||
|
||||
useRedirectOnSignIn(redirectPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AuthLinkRedirect;
|
||||
|
||||
function useRedirectOnSignIn(redirectPath: string) {
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange((_, session) => {
|
||||
if (session) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, router, redirectPath]);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { OauthProviderLogoImage } from './oauth-provider-logo-image';
|
||||
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||
|
||||
export function AuthProviderButton({
|
||||
providerId,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { UserCheck } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
|
||||
interface ExistingAccountHintProps {
|
||||
signInPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// we force dynamic import to avoid hydration errors
|
||||
export const ExistingAccountHint = dynamic(
|
||||
async () => ({ default: ExistingAccountHintImpl }),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function ExistingAccountHintImpl({
|
||||
signInPath = '/auth/sign-in',
|
||||
className,
|
||||
}: ExistingAccountHintProps) {
|
||||
const { hasLastMethod, methodType, providerName, isOAuth } =
|
||||
useLastAuthMethod();
|
||||
|
||||
// Get the appropriate method description for the hint
|
||||
// This must be called before any conditional returns to follow Rules of Hooks
|
||||
const methodDescription = useMemo(() => {
|
||||
if (isOAuth && providerName) {
|
||||
return providerName;
|
||||
}
|
||||
|
||||
switch (methodType) {
|
||||
case 'password':
|
||||
return 'email and password';
|
||||
case 'otp':
|
||||
return 'email verification';
|
||||
case 'magic_link':
|
||||
return 'email link';
|
||||
default:
|
||||
return 'another method';
|
||||
}
|
||||
}, [methodType, isOAuth, providerName]);
|
||||
|
||||
// Don't show anything until loaded or if no last method
|
||||
if (!hasLastMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<If condition={Boolean(methodDescription)}>
|
||||
<Alert
|
||||
data-test={'existing-account-hint'}
|
||||
variant="info"
|
||||
className={className}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="auth:existingAccountHint"
|
||||
values={{ method: methodDescription }}
|
||||
components={{
|
||||
method: <span className="font-medium" />,
|
||||
signInLink: (
|
||||
<Link
|
||||
href={signInPath}
|
||||
className="font-medium underline hover:no-underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
|
||||
interface LastAuthMethodHintProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// we force dynamic import to avoid hydration errors
|
||||
export const LastAuthMethodHint = dynamic(
|
||||
async () => ({ default: LastAuthMethodHintImpl }),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
||||
const { hasLastMethod, methodType, providerName, isOAuth } =
|
||||
useLastAuthMethod();
|
||||
|
||||
// Get the appropriate translation key based on the method - memoized
|
||||
// This must be called before any conditional returns to follow Rules of Hooks
|
||||
const methodKey = useMemo(() => {
|
||||
switch (methodType) {
|
||||
case 'password':
|
||||
return 'auth:methodPassword';
|
||||
case 'otp':
|
||||
return 'auth:methodOtp';
|
||||
case 'magic_link':
|
||||
return 'auth:methodMagicLink';
|
||||
case 'oauth':
|
||||
return 'auth:methodOauth';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [methodType]);
|
||||
|
||||
// Don't show anything until loaded or if no last method
|
||||
if (!hasLastMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!methodKey) {
|
||||
return null; // If method is not recognized, don't render anything
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="last-auth-method-hint"
|
||||
className={`text-muted-foreground/80 flex items-center justify-center gap-2 text-xs ${className || ''}`}
|
||||
>
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
|
||||
<If condition={isOAuth && Boolean(providerName)}>
|
||||
<Trans
|
||||
i18nKey="auth:methodOauthWithProvider"
|
||||
values={{ provider: providerName }}
|
||||
components={{
|
||||
provider: <span className="text-muted-foreground font-medium" />,
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
<If condition={!isOAuth || !providerName}>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
<Trans i18nKey={methodKey} />
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
@@ -21,9 +20,11 @@ import {
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
|
||||
|
||||
export function MagicLinkAuthContainer({
|
||||
@@ -46,6 +47,7 @@ export function MagicLinkAuthContainer({
|
||||
const { t } = useTranslation();
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
const appEvents = useAppEvents();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
@@ -77,6 +79,8 @@ export function MagicLinkAuthContainer({
|
||||
},
|
||||
});
|
||||
|
||||
recordAuthMethod('magic_link', { email });
|
||||
|
||||
if (shouldCreateUser) {
|
||||
appEvents.emit({
|
||||
type: 'user.signedUp',
|
||||
@@ -90,7 +94,7 @@ export function MagicLinkAuthContainer({
|
||||
toast.promise(promise, {
|
||||
loading: t('auth:sendingEmailLink'),
|
||||
success: t(`auth:sendLinkSuccessToast`),
|
||||
error: t(`auth:errors.link`),
|
||||
error: t(`auth:errors.linkTitle`),
|
||||
});
|
||||
|
||||
resetCaptchaToken();
|
||||
@@ -103,11 +107,11 @@ export function MagicLinkAuthContainer({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<If condition={signInWithOtpMutation.error}>
|
||||
<ErrorAlert />
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<If condition={signInWithOtpMutation.error}>
|
||||
<ErrorAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
@@ -171,11 +175,11 @@ function ErrorAlert() {
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:errors.generic'} />
|
||||
<Trans i18nKey={'auth:errors.linkTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:errors.link'} />
|
||||
<Trans i18nKey={'auth:errors.linkDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { If } from '@kit/ui/if';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { AuthProviderButton } from './auth-provider-button';
|
||||
|
||||
@@ -42,6 +43,7 @@ export const OauthProviders: React.FC<{
|
||||
};
|
||||
}> = (props) => {
|
||||
const signInWithProviderMutation = useSignInWithProvider();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
|
||||
// we make the UI "busy" until the next page is fully loaded
|
||||
const loading = signInWithProviderMutation.isPending;
|
||||
@@ -105,9 +107,15 @@ export const OauthProviders: React.FC<{
|
||||
},
|
||||
} satisfies SignInWithOAuthCredentials;
|
||||
|
||||
return onSignInWithProvider(() =>
|
||||
signInWithProviderMutation.mutateAsync(credentials),
|
||||
);
|
||||
return onSignInWithProvider(async () => {
|
||||
const result =
|
||||
await signInWithProviderMutation.mutateAsync(credentials);
|
||||
|
||||
// Record successful OAuth sign-in
|
||||
recordAuthMethod('oauth', { provider });
|
||||
|
||||
return result;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
|
||||
249
packages/features/auth/src/components/otp-sign-in-container.tsx
Normal file
249
packages/features/auth/src/components/otp-sign-in-container.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@kit/ui/input-otp';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
|
||||
const EmailSchema = z.object({ email: z.string().email() });
|
||||
const OtpSchema = z.object({ token: z.string().min(6).max(6) });
|
||||
|
||||
export function OtpSignInContainer({
|
||||
onSignIn,
|
||||
shouldCreateUser,
|
||||
}: {
|
||||
onSignIn?: (userId?: string) => void;
|
||||
shouldCreateUser: boolean;
|
||||
}) {
|
||||
const verifyMutation = useVerifyOtp();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
|
||||
const otpForm = useForm({
|
||||
resolver: zodResolver(OtpSchema.merge(EmailSchema)),
|
||||
defaultValues: {
|
||||
token: '',
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
const email = useWatch({
|
||||
control: otpForm.control,
|
||||
name: 'email',
|
||||
});
|
||||
|
||||
const isEmailStep = !email;
|
||||
|
||||
const handleVerifyOtp = async ({
|
||||
token,
|
||||
email,
|
||||
}: {
|
||||
token: string;
|
||||
email: string;
|
||||
}) => {
|
||||
const result = await verifyMutation.mutateAsync({
|
||||
type: 'email',
|
||||
email,
|
||||
token,
|
||||
});
|
||||
|
||||
// Record successful OTP sign-in
|
||||
recordAuthMethod('otp', { email });
|
||||
|
||||
if (onSignIn) {
|
||||
return onSignIn(result?.user?.id);
|
||||
}
|
||||
|
||||
// on sign ups we redirect to the app home
|
||||
if (shouldCreateUser) {
|
||||
const next = params.get('next') ?? '/home';
|
||||
|
||||
router.replace(next);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEmailStep) {
|
||||
return (
|
||||
<OtpEmailForm
|
||||
shouldCreateUser={shouldCreateUser}
|
||||
onSendOtp={(email) => {
|
||||
otpForm.setValue('email', email, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...otpForm}>
|
||||
<form
|
||||
className="flex w-full flex-col items-center space-y-8"
|
||||
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
|
||||
>
|
||||
<AuthErrorAlert error={verifyMutation.error} />
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
disabled={verifyMutation.isPending}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} data-slot="0" />
|
||||
<InputOTPSlot index={1} data-slot="1" />
|
||||
<InputOTPSlot index={2} data-slot="2" />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} data-slot="3" />
|
||||
<InputOTPSlot index={4} data-slot="4" />
|
||||
<InputOTPSlot index={5} data-slot="5" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey="common:otp.enterCodeFromEmail" />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-col gap-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifyMutation.isPending}
|
||||
data-test="otp-verify-button"
|
||||
>
|
||||
{verifyMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="common:otp.verifying" />
|
||||
</>
|
||||
) : (
|
||||
<Trans i18nKey="common:otp.verifyCode" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={verifyMutation.isPending}
|
||||
onClick={() => {
|
||||
otpForm.setValue('email', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="common:otp.requestNewCode" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function OtpEmailForm({
|
||||
shouldCreateUser,
|
||||
onSendOtp,
|
||||
}: {
|
||||
shouldCreateUser: boolean;
|
||||
onSendOtp: (email: string) => void;
|
||||
}) {
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
const signInMutation = useSignInWithOtp();
|
||||
|
||||
const emailForm = useForm({
|
||||
resolver: zodResolver(EmailSchema),
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => {
|
||||
await signInMutation.mutateAsync({
|
||||
email,
|
||||
options: { captchaToken, shouldCreateUser },
|
||||
});
|
||||
|
||||
resetCaptchaToken();
|
||||
onSendOtp(email);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...emailForm}>
|
||||
<form
|
||||
className="flex flex-col gap-y-4"
|
||||
onSubmit={emailForm.handleSubmit(handleSendOtp)}
|
||||
>
|
||||
<AuthErrorAlert error={signInMutation.error} />
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
data-test="otp-email-input"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={signInMutation.isPending}
|
||||
data-test="otp-send-button"
|
||||
>
|
||||
{signInMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="common:otp.sendingCode" />
|
||||
</>
|
||||
) : (
|
||||
<Trans i18nKey="common:otp.sendVerificationCode" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { z } from 'zod';
|
||||
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignInForm } from './password-sign-in-form';
|
||||
@@ -18,6 +19,7 @@ export function PasswordSignInContainer({
|
||||
}) {
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
const signInMutation = useSignInWithEmailPassword();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
const isLoading = signInMutation.isPending;
|
||||
const isRedirecting = signInMutation.isSuccess;
|
||||
|
||||
@@ -29,6 +31,9 @@ export function PasswordSignInContainer({
|
||||
options: { captchaToken },
|
||||
});
|
||||
|
||||
// Record successful password sign-in
|
||||
recordAuthMethod('password', { email: credentials.email });
|
||||
|
||||
if (onSignIn) {
|
||||
const userId = data?.user?.id;
|
||||
|
||||
@@ -40,7 +45,13 @@ export function PasswordSignInContainer({
|
||||
resetCaptchaToken();
|
||||
}
|
||||
},
|
||||
[captchaToken, onSignIn, resetCaptchaToken, signInMutation],
|
||||
[
|
||||
captchaToken,
|
||||
onSignIn,
|
||||
resetCaptchaToken,
|
||||
signInMutation,
|
||||
recordAuthMethod,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
@@ -9,8 +11,10 @@ import { If } from '@kit/ui/if';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { LastAuthMethodHint } from './last-auth-method-hint';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { OtpSignInContainer } from './otp-sign-in-container';
|
||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||
|
||||
export function SignInMethodsContainer(props: {
|
||||
@@ -25,6 +29,7 @@ export function SignInMethodsContainer(props: {
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
otp: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
}) {
|
||||
@@ -34,7 +39,7 @@ export function SignInMethodsContainer(props: {
|
||||
? new URL(props.paths.callback, window?.location.origin).toString()
|
||||
: '';
|
||||
|
||||
const onSignIn = () => {
|
||||
const onSignIn = useCallback(() => {
|
||||
// if the user has an invite token, we should join the team
|
||||
if (props.inviteToken) {
|
||||
const searchParams = new URLSearchParams({
|
||||
@@ -50,10 +55,12 @@ export function SignInMethodsContainer(props: {
|
||||
// otherwise, we should redirect to the return path
|
||||
router.replace(returnPath);
|
||||
}
|
||||
};
|
||||
}, [props.inviteToken, props.paths.joinTeam, props.paths.returnPath, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LastAuthMethodHint />
|
||||
|
||||
<If condition={props.providers.password}>
|
||||
<PasswordSignInContainer onSignIn={onSignIn} />
|
||||
</If>
|
||||
@@ -66,6 +73,10 @@ export function SignInMethodsContainer(props: {
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<OtpSignInContainer shouldCreateUser={false} onSignIn={onSignIn} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
|
||||
@@ -8,8 +8,10 @@ import { If } from '@kit/ui/if';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { ExistingAccountHint } from './existing-account-hint';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { OtpSignInContainer } from './otp-sign-in-container';
|
||||
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
|
||||
|
||||
export function SignUpMethodsContainer(props: {
|
||||
@@ -21,6 +23,7 @@ export function SignUpMethodsContainer(props: {
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
otp: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
|
||||
@@ -32,6 +35,9 @@ export function SignUpMethodsContainer(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Show hint if user might already have an account */}
|
||||
<ExistingAccountHint />
|
||||
|
||||
<If condition={props.inviteToken}>
|
||||
<InviteAlert />
|
||||
</If>
|
||||
@@ -44,6 +50,10 @@ export function SignUpMethodsContainer(props: {
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<OtpSignInContainer shouldCreateUser={true} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
|
||||
78
packages/features/auth/src/hooks/use-last-auth-method.ts
Normal file
78
packages/features/auth/src/hooks/use-last-auth-method.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { AuthMethod, LastAuthMethod } from '../utils/last-auth-method';
|
||||
import {
|
||||
clearLastAuthMethod,
|
||||
getLastAuthMethod,
|
||||
saveLastAuthMethod,
|
||||
} from '../utils/last-auth-method';
|
||||
|
||||
export function useLastAuthMethod() {
|
||||
const [lastAuthMethod, setLastAuthMethod] = useState<LastAuthMethod | null>(
|
||||
getLastAuthMethod(),
|
||||
);
|
||||
|
||||
// Save a new auth method - memoized to prevent unnecessary re-renders
|
||||
const recordAuthMethod = useCallback(
|
||||
(
|
||||
method: AuthMethod,
|
||||
options?: {
|
||||
provider?: string;
|
||||
email?: string;
|
||||
},
|
||||
) => {
|
||||
const authMethod: LastAuthMethod = {
|
||||
method,
|
||||
provider: options?.provider,
|
||||
email: options?.email,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
saveLastAuthMethod(authMethod);
|
||||
setLastAuthMethod(authMethod);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Clear the stored auth method - memoized to prevent unnecessary re-renders
|
||||
const clearAuthMethod = useCallback(() => {
|
||||
clearLastAuthMethod();
|
||||
setLastAuthMethod(null);
|
||||
}, []);
|
||||
|
||||
// Compute derived values using useMemo for performance
|
||||
const derivedData = useMemo(() => {
|
||||
if (!lastAuthMethod) {
|
||||
return {
|
||||
hasLastMethod: false,
|
||||
methodType: null,
|
||||
providerName: null,
|
||||
isOAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isOAuth = lastAuthMethod.method === 'oauth';
|
||||
|
||||
const providerName =
|
||||
isOAuth && lastAuthMethod.provider
|
||||
? lastAuthMethod.provider.charAt(0).toUpperCase() +
|
||||
lastAuthMethod.provider.slice(1)
|
||||
: null;
|
||||
|
||||
return {
|
||||
hasLastMethod: true,
|
||||
methodType: lastAuthMethod.method,
|
||||
providerName,
|
||||
isOAuth,
|
||||
};
|
||||
}, [lastAuthMethod]);
|
||||
|
||||
return {
|
||||
lastAuthMethod,
|
||||
recordAuthMethod,
|
||||
clearAuthMethod,
|
||||
...derivedData,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
|
||||
|
||||
import { useLastAuthMethod } from './use-last-auth-method';
|
||||
|
||||
type SignUpCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -33,6 +35,7 @@ export function usePasswordSignUpFlow({
|
||||
const router = useRouter();
|
||||
const signUpMutation = useSignUpWithEmailAndPassword();
|
||||
const appEvents = useAppEvents();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
|
||||
const signUp = useCallback(
|
||||
async (credentials: SignUpCredentials) => {
|
||||
@@ -47,6 +50,9 @@ export function usePasswordSignUpFlow({
|
||||
captchaToken,
|
||||
});
|
||||
|
||||
// Record last auth method
|
||||
recordAuthMethod('password', { email: credentials.email });
|
||||
|
||||
// emit event to track sign up
|
||||
appEvents.emit({
|
||||
type: 'user.signedUp',
|
||||
@@ -58,6 +64,7 @@ export function usePasswordSignUpFlow({
|
||||
// Update URL with success status. This is useful for password managers
|
||||
// to understand that the form was submitted successfully.
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.searchParams.set('status', 'success');
|
||||
router.replace(url.pathname + url.search);
|
||||
|
||||
@@ -66,6 +73,7 @@ export function usePasswordSignUpFlow({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
resetCaptchaToken?.();
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './components/sign-in-methods-container';
|
||||
export * from './components/otp-sign-in-container';
|
||||
export * from './schemas/password-sign-in.schema';
|
||||
|
||||
71
packages/features/auth/src/utils/last-auth-method.ts
Normal file
71
packages/features/auth/src/utils/last-auth-method.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
|
||||
// Key for localStorage
|
||||
const LAST_AUTH_METHOD_KEY = 'auth_last_method';
|
||||
|
||||
// Types of authentication methods
|
||||
export type AuthMethod = 'password' | 'otp' | 'magic_link' | 'oauth';
|
||||
|
||||
export interface LastAuthMethod {
|
||||
method: AuthMethod;
|
||||
provider?: string; // For OAuth providers (e.g., 'google', 'github')
|
||||
email?: string; // Store email for method-specific hints
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the last used authentication method to localStorage
|
||||
*/
|
||||
export function saveLastAuthMethod(authMethod: LastAuthMethod): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_AUTH_METHOD_KEY, JSON.stringify(authMethod));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save last auth method:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last used authentication method from localStorage
|
||||
*/
|
||||
export function getLastAuthMethod() {
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(LAST_AUTH_METHOD_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as LastAuthMethod;
|
||||
|
||||
// Check if the stored method is older than 30 days
|
||||
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (parsed.timestamp < thirtyDaysAgo) {
|
||||
localStorage.removeItem(LAST_AUTH_METHOD_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get last auth method:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the last used authentication method from localStorage
|
||||
*/
|
||||
export function clearLastAuthMethod() {
|
||||
try {
|
||||
localStorage.removeItem(LAST_AUTH_METHOD_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear last auth method:', error);
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,12 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@types/react": "19.1.6",
|
||||
"lucide-react": "^0.513.0",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@types/react": "19.1.8",
|
||||
"lucide-react": "^0.514.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^15.5.2"
|
||||
"react-i18next": "^15.5.3"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"typesVersions": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
@@ -33,20 +33,19 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"sonner": "^2.0.5",
|
||||
"zod": "^3.25.56"
|
||||
"react-i18next": "^15.5.3",
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"typesVersions": {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useCallback } from 'react';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { ImageUploader } from '@kit/ui/image-uploader';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const AVATARS_BUCKET = 'account_image';
|
||||
|
||||
@@ -7,7 +7,6 @@ import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { TeamNameFormSchema } from '../../schema/update-team-name.schema';
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^15.5.2"
|
||||
"react-i18next": "^15.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.2.1",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"i18next-resources-to-backend": "^1.2.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/resend": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.30",
|
||||
"zod": "^3.25.56"
|
||||
"@types/node": "^24.0.1",
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
"@kit/mailers-shared": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.30",
|
||||
"zod": "^3.25.56"
|
||||
"@types/node": "^24.0.1",
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
"@kit/sentry": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -10,11 +10,9 @@ export function BaselimeProvider({
|
||||
children,
|
||||
apiKey,
|
||||
enableWebVitals,
|
||||
ErrorPage,
|
||||
}: React.PropsWithChildren<{
|
||||
apiKey?: string;
|
||||
enableWebVitals?: boolean;
|
||||
ErrorPage?: React.ReactElement;
|
||||
}>) {
|
||||
const key = apiKey ?? process.env.NEXT_PUBLIC_BASELIME_KEY ?? '';
|
||||
|
||||
@@ -28,11 +26,7 @@ export function BaselimeProvider({
|
||||
}
|
||||
|
||||
return (
|
||||
<BaselimeRum
|
||||
apiKey={key}
|
||||
enableWebVitals={enableWebVitals}
|
||||
fallback={ErrorPage ?? null}
|
||||
>
|
||||
<BaselimeRum apiKey={key} enableWebVitals={enableWebVitals}>
|
||||
<MonitoringProvider>{children}</MonitoringProvider>
|
||||
</BaselimeRum>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"react": "19.1.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"./config/server": "./src/sentry.client.server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "^9.27.0",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"import-in-the-middle": "1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@kit/monitoring-core": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"react": "19.1.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"next": "15.3.3",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/mailers": "workspace:*",
|
||||
@@ -26,12 +26,12 @@
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6"
|
||||
"@types/react": "19.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^9.7.0"
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@types/react": "19.1.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@types/react": "19.1.8",
|
||||
"next": "15.3.3",
|
||||
"react": "19.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
import { USER_IDENTITIES_QUERY_KEY } from './use-user-identities';
|
||||
|
||||
interface Credentials {
|
||||
email: string;
|
||||
password: string;
|
||||
redirectTo: string;
|
||||
}
|
||||
|
||||
export function useLinkIdentityWithEmailPassword() {
|
||||
const client = useSupabase();
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = ['auth', 'link-email-password'];
|
||||
|
||||
const mutationFn = async (credentials: Credentials) => {
|
||||
const { email, password, redirectTo } = credentials;
|
||||
|
||||
const { error } = await client.auth.updateUser(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
data: {
|
||||
// This is used to indicate that the user has a password set
|
||||
// because Supabase does not add the identity after setting a password
|
||||
// if the user was created with oAuth
|
||||
hasPassword: true,
|
||||
},
|
||||
},
|
||||
{ emailRedirectTo: redirectTo },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error.message ?? error;
|
||||
}
|
||||
|
||||
await client.auth.refreshSession();
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: USER_IDENTITIES_QUERY_KEY,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useLinkIdentityWithProvider(
|
||||
props: {
|
||||
redirectToPath?: string;
|
||||
} = {},
|
||||
) {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'link-identity'];
|
||||
|
||||
const mutationFn = async (provider: Provider) => {
|
||||
const origin = window.location.origin;
|
||||
const redirectToPath = props.redirectToPath ?? '/home/settings';
|
||||
|
||||
const url = new URL('/auth/callback', origin);
|
||||
url.searchParams.set('redirectTo', redirectToPath);
|
||||
|
||||
const { error: linkError } = await client.auth.linkIdentity({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: url.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (linkError) {
|
||||
throw linkError.message ?? linkError;
|
||||
}
|
||||
|
||||
await client.auth.refreshSession();
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
22
packages/supabase/src/hooks/use-unlink-identity.ts
Normal file
22
packages/supabase/src/hooks/use-unlink-identity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { UserIdentity } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useUnlinkIdentity() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'unlink-identity'];
|
||||
|
||||
const mutationFn = async (identity: UserIdentity) => {
|
||||
const { error } = await client.auth.unlinkIdentity(identity);
|
||||
|
||||
if (error) {
|
||||
throw error.message ?? error;
|
||||
}
|
||||
|
||||
await client.auth.refreshSession();
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
30
packages/supabase/src/hooks/use-unlink-user-identity.ts
Normal file
30
packages/supabase/src/hooks/use-unlink-user-identity.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { UserIdentity } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
import { USER_IDENTITIES_QUERY_KEY } from './use-user-identities';
|
||||
|
||||
export function useUnlinkUserIdentity() {
|
||||
const supabase = useSupabase();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (identity: UserIdentity) => {
|
||||
// Unlink the identity
|
||||
const { error } = await supabase.auth.unlinkIdentity(identity);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return identity;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch user identities
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: USER_IDENTITIES_QUERY_KEY,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
56
packages/supabase/src/hooks/use-user-identities.ts
Normal file
56
packages/supabase/src/hooks/use-user-identities.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export const USER_IDENTITIES_QUERY_KEY = ['user-identities'];
|
||||
|
||||
export function useUserIdentities() {
|
||||
const supabase = useSupabase();
|
||||
|
||||
const {
|
||||
data: identities = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: USER_IDENTITIES_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.auth.getUserIdentities();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.identities;
|
||||
},
|
||||
});
|
||||
|
||||
const connectedProviders = useMemo(() => {
|
||||
return identities.map((identity) => identity.provider);
|
||||
}, [identities]);
|
||||
|
||||
const hasMultipleIdentities = identities.length > 1;
|
||||
|
||||
const getIdentityByProvider = (provider: Provider) => {
|
||||
return identities.find((identity) => identity.provider === provider);
|
||||
};
|
||||
|
||||
const isProviderConnected = (provider: Provider) => {
|
||||
return connectedProviders.includes(provider);
|
||||
};
|
||||
|
||||
return {
|
||||
identities,
|
||||
connectedProviders,
|
||||
hasMultipleIdentities,
|
||||
getIdentityByProvider,
|
||||
isProviderConnected,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -33,19 +33,19 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"react-top-loading-bar": "3.0.2",
|
||||
"recharts": "2.15.3",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -55,12 +55,12 @@
|
||||
"prettier": "^3.5.3",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-i18next": "^15.5.3",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwindcss": "4.1.8",
|
||||
"tailwindcss": "4.1.10",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.8.3",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"imports": {
|
||||
@@ -129,7 +129,8 @@
|
||||
"./multi-step-form": "./src/makerkit/multi-step-form.tsx",
|
||||
"./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx",
|
||||
"./empty-state": "./src/makerkit/empty-state.tsx",
|
||||
"./marketing": "./src/makerkit/marketing/index.tsx"
|
||||
"./marketing": "./src/makerkit/marketing/index.tsx",
|
||||
"./oauth-provider-logo-image": "./src/makerkit/oauth-provider-logo-image.tsx"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -33,8 +33,8 @@ export function OauthProviderLogoImage({
|
||||
|
||||
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
||||
return {
|
||||
password: <AtSign className={'s-[18px]'} />,
|
||||
phone: <Phone className={'s-[18px]'} />,
|
||||
email: <AtSign className={'size-[18px]'} />,
|
||||
phone: <Phone className={'size-[18x]'} />,
|
||||
google: '/images/oauth/google.webp',
|
||||
facebook: '/images/oauth/facebook.webp',
|
||||
github: '/images/oauth/github.webp',
|
||||
@@ -46,11 +46,7 @@ function PageWithSidebar(props: PageProps) {
|
||||
>
|
||||
{MobileNavigation}
|
||||
|
||||
<div
|
||||
className={
|
||||
'bg-background flex flex-1 flex-col px-4 lg:px-0'
|
||||
}
|
||||
>
|
||||
<div className={'bg-background flex flex-1 flex-col px-4 lg:px-0'}>
|
||||
{Children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user