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

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