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:
Giancarlo Buomprisco
2025-06-13 16:47:35 +07:00
committed by GitHub
parent 856e9612c4
commit 9033155fcd
87 changed files with 2580 additions and 1172 deletions

View File

@@ -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": {
"*": {

View File

@@ -21,7 +21,7 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"zod": "^3.25.56"
"zod": "^3.25.63"
},
"typesVersions": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -20,7 +20,7 @@
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*",
"@types/node": "^22.15.30"
"@types/node": "^24.0.1"
},
"typesVersions": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -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": {

View File

@@ -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": {
"*": {

View File

@@ -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:*",

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
export * from './account-settings-container';
export * from './link-accounts';

View File

@@ -0,0 +1 @@
export * from './link-accounts-list';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

@@ -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?.();

View File

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

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

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -16,7 +16,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"zod": "^3.25.56"
"zod": "^3.25.63"
},
"typesVersions": {
"*": {

View File

@@ -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": {
"*": {

View File

@@ -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": {
"*": {

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {
"*": {

View File

@@ -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": {
"*": {

View File

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

View File

@@ -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": {
"*": {

View File

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

View File

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

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

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

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

View File

@@ -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": {
"*": {

View File

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

View File

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