diff --git a/package-lock.json b/package-lock.json index d5770d70..1ebc2b63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "@scure/base": "^1.1.7", "@scure/bip39": "^1.3.0", + "@simplewebauthn/browser": "^10.0.0", "@tanstack/react-table": "^8.19.2", "argon2-browser": "^1.18.0", "big.js": "^6.2.1", @@ -73,6 +74,7 @@ "@graphql-codegen/typescript-operations": "^4.2.3", "@graphql-codegen/typescript-react-apollo": "^4.3.0", "@graphql-codegen/typescript-resolvers": "^4.2.1", + "@simplewebauthn/types": "^10.0.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@types/argon2-browser": "^1.18.4", @@ -6120,6 +6122,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@simplewebauthn/browser": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz", + "integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==", + "dependencies": { + "@simplewebauthn/types": "^10.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz", + "integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", diff --git a/package.json b/package.json index 798b4518..195398b3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "@scure/base": "^1.1.7", "@scure/bip39": "^1.3.0", + "@simplewebauthn/browser": "^10.0.0", "@tanstack/react-table": "^8.19.2", "argon2-browser": "^1.18.0", "big.js": "^6.2.1", @@ -81,6 +82,7 @@ "@graphql-codegen/typescript-operations": "^4.2.3", "@graphql-codegen/typescript-react-apollo": "^4.3.0", "@graphql-codegen/typescript-resolvers": "^4.2.1", + "@simplewebauthn/types": "^10.0.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@types/argon2-browser": "^1.18.4", diff --git a/src/graphql/mutations/__generated__/passkey.generated.tsx b/src/graphql/mutations/__generated__/passkey.generated.tsx new file mode 100644 index 00000000..f6e336e3 --- /dev/null +++ b/src/graphql/mutations/__generated__/passkey.generated.tsx @@ -0,0 +1,293 @@ +/* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY. */ +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; + +import * as Types from '../../types'; + +const defaultOptions = {} as const; +export type TwoFactorPasskeyAddMutationVariables = Types.Exact<{ + [key: string]: never; +}>; + +export type TwoFactorPasskeyAddMutation = { + __typename?: 'Mutation'; + two_factor: { + __typename?: 'TwoFactorMutations'; + passkey: { + __typename?: 'TwoFactorPasskeyMutations'; + add: { __typename?: 'CreateTwoFactorPasskey'; options: string }; + }; + }; +}; + +export type TwoFactorPasskeyVerifyMutationVariables = Types.Exact<{ + options: Types.Scalars['String']['input']; +}>; + +export type TwoFactorPasskeyVerifyMutation = { + __typename?: 'Mutation'; + two_factor: { + __typename?: 'TwoFactorMutations'; + passkey: { __typename?: 'TwoFactorPasskeyMutations'; verify: boolean }; + }; +}; + +export type TwoFactorPasskeyAuthInitMutationVariables = Types.Exact<{ + input: Types.TwoFactorPasskeyAuthInput; +}>; + +export type TwoFactorPasskeyAuthInitMutation = { + __typename?: 'Mutation'; + login: { + __typename?: 'LoginMutations'; + two_factor: { + __typename?: 'TwoFactorLoginMutations'; + passkey: { + __typename?: 'TwoFactorPasskeyLoginMutations'; + options: string; + }; + }; + }; +}; + +export type TwoFactorPasskeyAuthLoginMutationVariables = Types.Exact<{ + input: Types.TwoFactorPasskeyAuthLoginInput; +}>; + +export type TwoFactorPasskeyAuthLoginMutation = { + __typename?: 'Mutation'; + login: { + __typename?: 'LoginMutations'; + two_factor: { + __typename?: 'TwoFactorLoginMutations'; + passkey: { + __typename?: 'TwoFactorPasskeyLoginMutations'; + login: { + __typename?: 'Login'; + access_token?: string | null; + refresh_token?: string | null; + }; + }; + }; + }; +}; + +export const TwoFactorPasskeyAddDocument = gql` + mutation TwoFactorPasskeyAdd { + two_factor { + passkey { + add { + options + } + } + } + } +`; +export type TwoFactorPasskeyAddMutationFn = Apollo.MutationFunction< + TwoFactorPasskeyAddMutation, + TwoFactorPasskeyAddMutationVariables +>; + +/** + * __useTwoFactorPasskeyAddMutation__ + * + * To run a mutation, you first call `useTwoFactorPasskeyAddMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useTwoFactorPasskeyAddMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [twoFactorPasskeyAddMutation, { data, loading, error }] = useTwoFactorPasskeyAddMutation({ + * variables: { + * }, + * }); + */ +export function useTwoFactorPasskeyAddMutation( + baseOptions?: Apollo.MutationHookOptions< + TwoFactorPasskeyAddMutation, + TwoFactorPasskeyAddMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + TwoFactorPasskeyAddMutation, + TwoFactorPasskeyAddMutationVariables + >(TwoFactorPasskeyAddDocument, options); +} +export type TwoFactorPasskeyAddMutationHookResult = ReturnType< + typeof useTwoFactorPasskeyAddMutation +>; +export type TwoFactorPasskeyAddMutationResult = + Apollo.MutationResult; +export type TwoFactorPasskeyAddMutationOptions = Apollo.BaseMutationOptions< + TwoFactorPasskeyAddMutation, + TwoFactorPasskeyAddMutationVariables +>; +export const TwoFactorPasskeyVerifyDocument = gql` + mutation TwoFactorPasskeyVerify($options: String!) { + two_factor { + passkey { + verify(options: $options) + } + } + } +`; +export type TwoFactorPasskeyVerifyMutationFn = Apollo.MutationFunction< + TwoFactorPasskeyVerifyMutation, + TwoFactorPasskeyVerifyMutationVariables +>; + +/** + * __useTwoFactorPasskeyVerifyMutation__ + * + * To run a mutation, you first call `useTwoFactorPasskeyVerifyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useTwoFactorPasskeyVerifyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [twoFactorPasskeyVerifyMutation, { data, loading, error }] = useTwoFactorPasskeyVerifyMutation({ + * variables: { + * options: // value for 'options' + * }, + * }); + */ +export function useTwoFactorPasskeyVerifyMutation( + baseOptions?: Apollo.MutationHookOptions< + TwoFactorPasskeyVerifyMutation, + TwoFactorPasskeyVerifyMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + TwoFactorPasskeyVerifyMutation, + TwoFactorPasskeyVerifyMutationVariables + >(TwoFactorPasskeyVerifyDocument, options); +} +export type TwoFactorPasskeyVerifyMutationHookResult = ReturnType< + typeof useTwoFactorPasskeyVerifyMutation +>; +export type TwoFactorPasskeyVerifyMutationResult = + Apollo.MutationResult; +export type TwoFactorPasskeyVerifyMutationOptions = Apollo.BaseMutationOptions< + TwoFactorPasskeyVerifyMutation, + TwoFactorPasskeyVerifyMutationVariables +>; +export const TwoFactorPasskeyAuthInitDocument = gql` + mutation TwoFactorPasskeyAuthInit($input: TwoFactorPasskeyAuthInput!) { + login { + two_factor { + passkey { + options(input: $input) + } + } + } + } +`; +export type TwoFactorPasskeyAuthInitMutationFn = Apollo.MutationFunction< + TwoFactorPasskeyAuthInitMutation, + TwoFactorPasskeyAuthInitMutationVariables +>; + +/** + * __useTwoFactorPasskeyAuthInitMutation__ + * + * To run a mutation, you first call `useTwoFactorPasskeyAuthInitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useTwoFactorPasskeyAuthInitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [twoFactorPasskeyAuthInitMutation, { data, loading, error }] = useTwoFactorPasskeyAuthInitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useTwoFactorPasskeyAuthInitMutation( + baseOptions?: Apollo.MutationHookOptions< + TwoFactorPasskeyAuthInitMutation, + TwoFactorPasskeyAuthInitMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + TwoFactorPasskeyAuthInitMutation, + TwoFactorPasskeyAuthInitMutationVariables + >(TwoFactorPasskeyAuthInitDocument, options); +} +export type TwoFactorPasskeyAuthInitMutationHookResult = ReturnType< + typeof useTwoFactorPasskeyAuthInitMutation +>; +export type TwoFactorPasskeyAuthInitMutationResult = + Apollo.MutationResult; +export type TwoFactorPasskeyAuthInitMutationOptions = + Apollo.BaseMutationOptions< + TwoFactorPasskeyAuthInitMutation, + TwoFactorPasskeyAuthInitMutationVariables + >; +export const TwoFactorPasskeyAuthLoginDocument = gql` + mutation TwoFactorPasskeyAuthLogin($input: TwoFactorPasskeyAuthLoginInput!) { + login { + two_factor { + passkey { + login(input: $input) { + access_token + refresh_token + } + } + } + } + } +`; +export type TwoFactorPasskeyAuthLoginMutationFn = Apollo.MutationFunction< + TwoFactorPasskeyAuthLoginMutation, + TwoFactorPasskeyAuthLoginMutationVariables +>; + +/** + * __useTwoFactorPasskeyAuthLoginMutation__ + * + * To run a mutation, you first call `useTwoFactorPasskeyAuthLoginMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useTwoFactorPasskeyAuthLoginMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [twoFactorPasskeyAuthLoginMutation, { data, loading, error }] = useTwoFactorPasskeyAuthLoginMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useTwoFactorPasskeyAuthLoginMutation( + baseOptions?: Apollo.MutationHookOptions< + TwoFactorPasskeyAuthLoginMutation, + TwoFactorPasskeyAuthLoginMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + TwoFactorPasskeyAuthLoginMutation, + TwoFactorPasskeyAuthLoginMutationVariables + >(TwoFactorPasskeyAuthLoginDocument, options); +} +export type TwoFactorPasskeyAuthLoginMutationHookResult = ReturnType< + typeof useTwoFactorPasskeyAuthLoginMutation +>; +export type TwoFactorPasskeyAuthLoginMutationResult = + Apollo.MutationResult; +export type TwoFactorPasskeyAuthLoginMutationOptions = + Apollo.BaseMutationOptions< + TwoFactorPasskeyAuthLoginMutation, + TwoFactorPasskeyAuthLoginMutationVariables + >; diff --git a/src/graphql/mutations/passkey.ts b/src/graphql/mutations/passkey.ts new file mode 100644 index 00000000..2dc5b003 --- /dev/null +++ b/src/graphql/mutations/passkey.ts @@ -0,0 +1,50 @@ +import { gql } from '@apollo/client'; + +export const TwoFactorPasskeyAdd = gql` + mutation TwoFactorPasskeyAdd { + two_factor { + passkey { + add { + options + } + } + } + } +`; + +export const TwoFactorPasskeyVerify = gql` + mutation TwoFactorPasskeyVerify($options: String!) { + two_factor { + passkey { + verify(options: $options) + } + } + } +`; + +export const TwoFactorPasskeyAuthInit = gql` + mutation TwoFactorPasskeyAuthInit($input: TwoFactorPasskeyAuthInput!) { + login { + two_factor { + passkey { + options(input: $input) + } + } + } + } +`; + +export const TwoFactorPasskeyAuthLogin = gql` + mutation TwoFactorPasskeyAuthLogin($input: TwoFactorPasskeyAuthLoginInput!) { + login { + two_factor { + passkey { + login(input: $input) { + access_token + refresh_token + } + } + } + } + } +`; diff --git a/src/graphql/queries/2fa.ts b/src/graphql/queries/2fa.ts index b1ed0a46..f9f292ab 100644 --- a/src/graphql/queries/2fa.ts +++ b/src/graphql/queries/2fa.ts @@ -9,6 +9,7 @@ export const GetWalletSwaps = gql` created_at method enabled + passkey_name } } } diff --git a/src/graphql/queries/__generated__/2fa.generated.tsx b/src/graphql/queries/__generated__/2fa.generated.tsx index 0bc4001a..edc6b605 100644 --- a/src/graphql/queries/__generated__/2fa.generated.tsx +++ b/src/graphql/queries/__generated__/2fa.generated.tsx @@ -21,6 +21,7 @@ export type GetAccountTwoFactorMethodsQuery = { created_at: string; method: Types.TwoFactorMethod; enabled: boolean; + passkey_name: string; }>; }; }; @@ -34,6 +35,7 @@ export const GetAccountTwoFactorMethodsDocument = gql` created_at method enabled + passkey_name } } } diff --git a/src/graphql/types.ts b/src/graphql/types.ts index f411c786..2c756cfc 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -112,6 +112,11 @@ export type CreateTwoFactorOtp = { otp_url: Scalars['String']['output']; }; +export type CreateTwoFactorPasskey = { + __typename?: 'CreateTwoFactorPasskey'; + options: Scalars['String']['output']; +}; + export type CreateWallet = { __typename?: 'CreateWallet'; id: Scalars['String']['output']; @@ -475,6 +480,7 @@ export type SimpleTwoFactor = { enabled: Scalars['Boolean']['output']; id: Scalars['String']['output']; method: TwoFactorMethod; + passkey_name: Scalars['String']['output']; }; export type SimpleWallet = { @@ -543,6 +549,7 @@ export type TwoFactorLogin = { export type TwoFactorLoginMutations = { __typename?: 'TwoFactorLoginMutations'; otp: Login; + passkey: TwoFactorPasskeyLoginMutations; }; export type TwoFactorLoginMutationsOtpArgs = { @@ -557,6 +564,7 @@ export enum TwoFactorMethod { export type TwoFactorMutations = { __typename?: 'TwoFactorMutations'; otp: TwoFactorOtpMutations; + passkey: TwoFactorPasskeyMutations; }; export type TwoFactorOtpLogin = { @@ -578,6 +586,39 @@ export type TwoFactorOtpVerifyInput = { code: Scalars['String']['input']; }; +export type TwoFactorPasskeyAuthInput = { + session_id: Scalars['String']['input']; +}; + +export type TwoFactorPasskeyAuthLoginInput = { + options: Scalars['String']['input']; + session_id: Scalars['String']['input']; +}; + +export type TwoFactorPasskeyLoginMutations = { + __typename?: 'TwoFactorPasskeyLoginMutations'; + login: Login; + options: Scalars['String']['output']; +}; + +export type TwoFactorPasskeyLoginMutationsLoginArgs = { + input: TwoFactorPasskeyAuthLoginInput; +}; + +export type TwoFactorPasskeyLoginMutationsOptionsArgs = { + input: TwoFactorPasskeyAuthInput; +}; + +export type TwoFactorPasskeyMutations = { + __typename?: 'TwoFactorPasskeyMutations'; + add: CreateTwoFactorPasskey; + verify: Scalars['Boolean']['output']; +}; + +export type TwoFactorPasskeyMutationsVerifyArgs = { + options: Scalars['String']['input']; +}; + export type TwoFactorQueries = { __typename?: 'TwoFactorQueries'; find_many: Array; diff --git a/src/views/login/OTPForm.tsx b/src/views/login/OTPForm.tsx new file mode 100644 index 00000000..c079fdab --- /dev/null +++ b/src/views/login/OTPForm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { Loader2 } from 'lucide-react'; +import { FC, useState } from 'react'; + +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/components/ui/use-toast'; +import { useTwoFactorOtpLoginMutation } from '@/graphql/mutations/__generated__/otp.generated'; +import { handleApolloError } from '@/utils/error'; +import { ROUTES } from '@/utils/routes'; + +export const OTPForm: FC<{ session_id: string }> = ({ session_id }) => { + const [value, setValue] = useState(''); + + const { toast } = useToast(); + + const [login, { loading }] = useTwoFactorOtpLoginMutation({ + onCompleted: () => { + window.location.href = ROUTES.dashboard; + }, + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error logging in.', + description: messages.join(', '), + }); + + setValue(''); + }, + }); + + const handleOTPChange = (value: string) => { + if (loading) return; + setValue(value); + + if (value.length >= 6) { + login({ variables: { input: { code: value, session_id } } }); + } + }; + + return ( +
+
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/views/login/PasskeyForm.tsx b/src/views/login/PasskeyForm.tsx new file mode 100644 index 00000000..df39ce6c --- /dev/null +++ b/src/views/login/PasskeyForm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { startAuthentication } from '@simplewebauthn/browser'; +import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'; +import { FC } from 'react'; + +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/use-toast'; +import { + useTwoFactorPasskeyAuthInitMutation, + useTwoFactorPasskeyAuthLoginMutation, +} from '@/graphql/mutations/__generated__/passkey.generated'; +import { handleApolloError } from '@/utils/error'; +import { ROUTES } from '@/utils/routes'; + +export const PasskeyForm: FC<{ session_id: string }> = ({ session_id }) => { + const { toast } = useToast(); + + const [init, { loading: initLoading }] = useTwoFactorPasskeyAuthInitMutation({ + onCompleted: data => { + try { + handleAuthentication(JSON.parse(data.login.two_factor.passkey.options)); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Error adding Passkey.', + }); + } + }, + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error getting Passkey details.', + description: messages.join(', '), + }); + }, + }); + + const [verify, { loading: verifyLoading }] = + useTwoFactorPasskeyAuthLoginMutation({ + onCompleted: () => { + window.location.href = ROUTES.dashboard; + }, + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error logging in.', + description: messages.join(', '), + }); + }, + }); + + const handleAuthentication = async ( + options: PublicKeyCredentialRequestOptionsJSON + ) => { + try { + const response = await startAuthentication(options); + + verify({ + variables: { input: { session_id, options: JSON.stringify(response) } }, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Error authenticating with Passkey.', + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + return ( + + ); +}; diff --git a/src/views/login/TwoFASteps.tsx b/src/views/login/TwoFASteps.tsx index 3753860f..713fe469 100644 --- a/src/views/login/TwoFASteps.tsx +++ b/src/views/login/TwoFASteps.tsx @@ -1,113 +1,58 @@ 'use client'; -import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { Loader2 } from 'lucide-react'; -import { FC, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { FC, useMemo, useState } from 'react'; -import { - InputOTP, - InputOTPGroup, - InputOTPSeparator, - InputOTPSlot, -} from '@/components/ui/input-otp'; -import { Label } from '@/components/ui/label'; -import { useToast } from '@/components/ui/use-toast'; +import { Button } from '@/components/ui/button'; import { LoginMutation } from '@/graphql/mutations/__generated__/login.generated'; -import { useTwoFactorOtpLoginMutation } from '@/graphql/mutations/__generated__/otp.generated'; import { TwoFactorMethod } from '@/graphql/types'; -import { handleApolloError } from '@/utils/error'; -import { ROUTES } from '@/utils/routes'; -const OTPForm: FC<{ session_id: string }> = ({ session_id }) => { - const [value, setValue] = useState(''); +import { OTPForm } from './OTPForm'; +import { PasskeyForm } from './PasskeyForm'; - const { toast } = useToast(); - - const [login, { loading }] = useTwoFactorOtpLoginMutation({ - onCompleted: () => { - window.location.href = ROUTES.dashboard; - }, - onError: err => { - const messages = handleApolloError(err); +export const TwoFASteps: FC<{ + methods: LoginMutation['login']['initial']['two_factor']; +}> = ({ methods }) => { + const [form, setForm] = useState(); - toast({ - variant: 'destructive', - title: 'Error logging in.', - description: messages.join(', '), - }); + const methodInfo = useMemo(() => { + const methodList = methods?.methods || []; - setValue(''); - }, - }); + const hasOTP = methodList.some(m => m.method === TwoFactorMethod.Otp); + const hasPasskey = methodList.some( + m => m.method === TwoFactorMethod.Passkey + ); - const handleOTPChange = (value: string) => { - if (loading) return; - setValue(value); + const differentAvailableTypes = [hasOTP ? 1 : 0, hasPasskey ? 1 : 0].reduce( + (p, c) => p + c, + 0 + ); - if (value.length >= 6) { - login({ variables: { input: { code: value, session_id } } }); - } - }; + return { + hasOTP, + hasPasskey, + differentAvailableTypes, + }; + }, [methods]); - return ( -
-
- {loading ? ( -
- -
- ) : ( - - )} -
-
- - - - - - - - - - - - - -
-
- ); -}; - -export const TwoFASteps: FC<{ - methods: LoginMutation['login']['initial']['two_factor']; -}> = ({ methods }) => { - const getMethodForm = ( - method: NonNullable< - LoginMutation['login']['initial']['two_factor'] - >['methods'][0] - ) => { + const getMethodForm = (method: TwoFactorMethod) => { if (!methods?.session_id) { return (

- {`Error loading ${method.method} form.`} + {`Error loading ${method} form.`}

); } - switch (method.method) { + switch (method) { case TwoFactorMethod.Otp: return ; case TwoFactorMethod.Passkey: + return ; default: return (

- {`Error loading ${method.method} form.`} + {`Error loading ${method} form.`}

); } @@ -121,13 +66,57 @@ export const TwoFASteps: FC<{ ); } - if (methods.methods.length === 1) { - return getMethodForm(methods.methods[0]); + if (methodInfo.differentAvailableTypes === 1) { + return getMethodForm(methods.methods[0].method); + } + + if (!!form) { + return ( +
+ {getMethodForm(form)} + +
+ ); } return ( -

- Error loading account 2FA methods. -

+
+

Two Factor Methods

+ {methodInfo.hasOTP ? ( + + ) : null} + + {methodInfo.hasPasskey ? ( + + ) : null} +
); }; diff --git a/src/views/settings/TwoFactor.tsx b/src/views/settings/TwoFactor.tsx index 04d23515..515d07df 100644 --- a/src/views/settings/TwoFactor.tsx +++ b/src/views/settings/TwoFactor.tsx @@ -1,212 +1,13 @@ 'use client'; -import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { Check, Copy, CopyCheck, Loader2 } from 'lucide-react'; -import { useQRCode } from 'next-qrcode'; -import { FC, useMemo, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { useMemo } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { - InputOTP, - InputOTPGroup, - InputOTPSeparator, - InputOTPSlot, -} from '@/components/ui/input-otp'; -import { Label } from '@/components/ui/label'; -import { useToast } from '@/components/ui/use-toast'; -import { - useTwoFactorOtpAddMutation, - useTwoFactorOtpVerifyMutation, -} from '@/graphql/mutations/__generated__/otp.generated'; import { useGetAccountTwoFactorMethodsQuery } from '@/graphql/queries/__generated__/2fa.generated'; import { TwoFactorMethod } from '@/graphql/types'; -import useCopyClipboard from '@/hooks/useClipboardCopy'; -import { handleApolloError } from '@/utils/error'; -import { Section } from './Section'; - -const OTP: FC<{ hasAlready: boolean }> = ({ hasAlready }) => { - const { Canvas } = useQRCode(); - - const { toast } = useToast(); - - const [value, setValue] = useState(''); - const [open, setOpen] = useState(false); - - const [setup, { data, loading }] = useTwoFactorOtpAddMutation({ - onError: err => { - const messages = handleApolloError(err); - - toast({ - variant: 'destructive', - title: 'Error getting 2FA details.', - description: messages.join(', '), - }); - }, - }); - - const [verify, { loading: verifyLoading }] = useTwoFactorOtpVerifyMutation({ - onCompleted: () => { - toast({ - title: 'OTP Setup', - description: 'OTP 2FA login has been configured for your account.', - }); - setValue(''); - setOpen(false); - }, - onError: err => { - const messages = handleApolloError(err); - - toast({ - variant: 'destructive', - title: 'Error verifying password.', - description: messages.join(', '), - }); - - setValue(''); - }, - refetchQueries: ['getAccountTwoFactorMethods'], - }); - - const [isCopied, copy] = useCopyClipboard(); - - const handleOTPChange = (value: string) => { - if (verifyLoading) return; - setValue(value); - - if (value.length >= 6) { - verify({ variables: { input: { code: value } } }); - } - }; - - return ( -
- { - if (loading || hasAlready) return; - setOpen(o => !o); - }} - > - - {hasAlready ? ( - - ) : ( - - )} - - - - - Setup OTP - - Scan the QR code below with your preferred authenticator app or - manually enter the code provided. Once set up, you will need to - enter a one-time password (OTP) generated by the app each time you - log in. - - - - {!data?.two_factor.otp.add.otp_url ? ( -
- -
- ) : ( -
- - -
- - -
-
- )} -
-
- {verifyLoading ? ( -
- -
- ) : ( - - )} -
-
- - - - - - - - - - - - - -
-
-
-
-
- ); -}; +import { OTP } from './TwoFactorOtp'; +import { Passkey } from './TwoFactorPasskey'; export const TwoFactor = () => { const { data, loading, error } = useGetAccountTwoFactorMethodsQuery(); @@ -235,6 +36,7 @@ export const TwoFactor = () => { return (
+
); }; diff --git a/src/views/settings/TwoFactorOtp.tsx b/src/views/settings/TwoFactorOtp.tsx new file mode 100644 index 00000000..fadbb277 --- /dev/null +++ b/src/views/settings/TwoFactorOtp.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { Check, Copy, CopyCheck, Loader2 } from 'lucide-react'; +import { useQRCode } from 'next-qrcode'; +import { FC, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/components/ui/use-toast'; +import { + useTwoFactorOtpAddMutation, + useTwoFactorOtpVerifyMutation, +} from '@/graphql/mutations/__generated__/otp.generated'; +import useCopyClipboard from '@/hooks/useClipboardCopy'; +import { handleApolloError } from '@/utils/error'; + +import { Section } from './Section'; + +export const OTP: FC<{ hasAlready: boolean }> = ({ hasAlready }) => { + const { Canvas } = useQRCode(); + + const { toast } = useToast(); + + const [value, setValue] = useState(''); + const [open, setOpen] = useState(false); + + const [setup, { data, loading }] = useTwoFactorOtpAddMutation({ + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error getting 2FA details.', + description: messages.join(', '), + }); + }, + }); + + const [verify, { loading: verifyLoading }] = useTwoFactorOtpVerifyMutation({ + onCompleted: () => { + toast({ + title: 'OTP Setup', + description: 'OTP 2FA login has been configured for your account.', + }); + setValue(''); + setOpen(false); + }, + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error verifying password.', + description: messages.join(', '), + }); + + setValue(''); + }, + refetchQueries: ['getAccountTwoFactorMethods'], + }); + + const [isCopied, copy] = useCopyClipboard(); + + const handleOTPChange = (value: string) => { + if (verifyLoading) return; + setValue(value); + + if (value.length >= 6) { + verify({ variables: { input: { code: value } } }); + } + }; + + return ( +
+ { + if (loading || hasAlready) return; + setOpen(o => !o); + }} + > + + {hasAlready ? ( + + ) : ( + + )} + + + + + Setup OTP + + Scan the QR code below with your preferred authenticator app or + manually enter the code provided. Once set up, you will need to + enter a one-time password (OTP) generated by the app each time you + log in. + + + + {!data?.two_factor.otp.add.otp_url ? ( +
+ +
+ ) : ( +
+ + +
+ + +
+
+ )} +
+
+ {verifyLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ + + + + + + + + + + + + +
+
+
+
+
+ ); +}; diff --git a/src/views/settings/TwoFactorPasskey.tsx b/src/views/settings/TwoFactorPasskey.tsx new file mode 100644 index 00000000..b85bc3b6 --- /dev/null +++ b/src/views/settings/TwoFactorPasskey.tsx @@ -0,0 +1,136 @@ +import { startRegistration } from '@simplewebauthn/browser'; +import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; +import { format } from 'date-fns'; +import { useMemo } from 'react'; + +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/use-toast'; +import { + useTwoFactorPasskeyAddMutation, + useTwoFactorPasskeyVerifyMutation, +} from '@/graphql/mutations/__generated__/passkey.generated'; +import { useGetAccountTwoFactorMethodsQuery } from '@/graphql/queries/__generated__/2fa.generated'; +import { TwoFactorMethod } from '@/graphql/types'; +import { handleApolloError } from '@/utils/error'; + +import { Section } from './Section'; + +const PasskeyList = () => { + const { data, loading, error } = useGetAccountTwoFactorMethodsQuery(); + + const passkeyMethods = useMemo(() => { + if (loading || error || !data?.two_factor.find_many.length) return []; + return data.two_factor.find_many.filter( + d => d.method === TwoFactorMethod.Passkey + ); + }, [data, loading, error]); + + if (loading || error || !passkeyMethods.length) { + return null; + } + + return ( +
+

Saved Passkeys

+
+ {passkeyMethods.map((d, index) => ( +
+
+

+ {index + 1}. {d.passkey_name || 'Passkey'} +

+

+ {format(d.created_at, 'MMM do, yyyy - HH:mm')} +

+
+
+ ))} +
+
+ ); +}; + +export const Passkey = () => { + const { toast } = useToast(); + + const [setup, { loading: addLoading }] = useTwoFactorPasskeyAddMutation({ + onCompleted: data => { + try { + handleRegistration(JSON.parse(data.two_factor.passkey.add.options)); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Error adding Passkey.', + }); + } + }, + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error getting Passkey details.', + description: messages.join(', '), + }); + }, + }); + + const [verify, { loading: verifyLoading }] = + useTwoFactorPasskeyVerifyMutation({ + onCompleted: () => { + toast({ + title: 'Passkey Setup', + description: + 'Passkey 2FA login has been configured for your account.', + }); + }, + onError: err => { + const messages = handleApolloError(err); + + toast({ + variant: 'destructive', + title: 'Error setting up Passkey.', + description: messages.join(', '), + }); + }, + refetchQueries: ['getAccountTwoFactorMethods'], + }); + + const handleRegistration = async ( + options: PublicKeyCredentialCreationOptionsJSON + ) => { + try { + const response = await startRegistration(options); + + verify({ variables: { options: JSON.stringify(response) } }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Error setting up Passkey.', + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + return ( +
+
+ + +
+
+ ); +};