Skip to content

Commit a2188e8

Browse files
committed
feat: add MfaProviderComponent for multi-factor authentication handling
1 parent 875d39b commit a2188e8

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client"
2+
3+
import React from "react";
4+
import {
5+
Button,
6+
Flex,
7+
InputMessage,
8+
SegmentedControl,
9+
SegmentedControlItem,
10+
Spacing,
11+
Text,
12+
useForm
13+
} from "@code0-tech/pictor";
14+
import {MfaInput, MfaType} from "@code0-tech/sagittarius-graphql-types";
15+
import {MfaInputComponent} from "@edition/user/components/MfaInputComponent";
16+
import {InputDialog} from "@core/components/InputDialog";
17+
18+
// Error codes signalling that an operation needs a fresh second factor.
19+
const MFA_TRIGGER_ERRORS = ["MFA_REQUIRED", "MFA_FAILED"]
20+
// Codes returned when a supplied code was wrong — keep the dialog open to retry.
21+
const MFA_RETRYABLE_ERRORS = ["MFA_REQUIRED", "MFA_FAILED", "WRONG_TOTP", "INVALID_TOTP_SECRET"]
22+
23+
interface MfaDialogOptions {
24+
error?: string
25+
}
26+
27+
// The dialog handle held below in React state and passed into withMfaRetry.
28+
interface MfaDialog {
29+
request: (options?: MfaDialogOptions) => Promise<MfaInput | null>
30+
close: () => void
31+
}
32+
33+
// Returned by useMfa(): wraps an operation with MFA step-up so callers only pass the operation.
34+
export interface WithMfa {
35+
<T extends PayloadWithErrors | undefined>(operation: (mfa?: MfaInput) => Promise<T>): Promise<T>
36+
}
37+
38+
interface ErrorLike {
39+
errorCode?: string | null
40+
}
41+
42+
interface PayloadWithErrors {
43+
errors?: (ErrorLike | null)[] | null
44+
}
45+
46+
const hasError = (payload: PayloadWithErrors | undefined, codes: string[]): boolean =>
47+
!!payload?.errors?.some(error => !!error?.errorCode && codes.includes(error.errorCode))
48+
49+
const requestAndRetry = async <T extends PayloadWithErrors | undefined>(
50+
operation: (mfa?: MfaInput) => Promise<T>,
51+
dialog: MfaDialog,
52+
previous: T,
53+
error?: string
54+
): Promise<T> => {
55+
const mfa = await dialog.request({error})
56+
if (!mfa) return previous // user cancelled — surface the MFA-required payload
57+
58+
const result = await operation(mfa)
59+
if (hasError(result, MFA_RETRYABLE_ERRORS)) {
60+
return requestAndRetry(operation, dialog, result, "The code you entered is invalid. Please try again.")
61+
}
62+
63+
dialog.close()
64+
return result
65+
}
66+
67+
const withMfaRetry = async <T extends PayloadWithErrors | undefined>(
68+
operation: (mfa?: MfaInput) => Promise<T>,
69+
dialog: MfaDialog
70+
): Promise<T> => {
71+
const result = await operation()
72+
if (!hasError(result, MFA_TRIGGER_ERRORS)) return result
73+
return requestAndRetry(operation, dialog, result)
74+
}
75+
76+
const MfaContext = React.createContext<WithMfa | null>(null)
77+
78+
export const useMfa = (): WithMfa => {
79+
const withMfa = React.useContext(MfaContext)
80+
if (!withMfa) throw new Error("useMfa must be used within an MfaProviderComponent")
81+
return withMfa
82+
}
83+
84+
export const MfaProviderComponent: React.FC<{ children: React.ReactNode }> = ({children}) => {
85+
86+
const [state, setState] = React.useState<{ open: boolean; error?: string; loading: boolean }>({
87+
open: false,
88+
loading: false
89+
})
90+
91+
const [requestId, setRequestId] = React.useState(0)
92+
const resolverRef = React.useRef<((value: MfaInput | null) => void) | null>(null)
93+
const [type, setType] = React.useState("TOTP")
94+
95+
const resolve = React.useCallback((value: MfaInput | null) => {
96+
const resolver = resolverRef.current
97+
resolverRef.current = null
98+
resolver?.(value)
99+
}, [])
100+
101+
const request = React.useCallback((options: MfaDialogOptions = {}) =>
102+
new Promise<MfaInput | null>((resolve) => {
103+
resolverRef.current?.(null)
104+
resolverRef.current = resolve
105+
setRequestId(id => id + 1)
106+
setState({open: true, error: options.error, loading: false})
107+
}), [])
108+
109+
const close = React.useCallback(() => {
110+
setState({open: false, error: undefined, loading: false})
111+
}, [])
112+
113+
const dialog = React.useMemo<MfaDialog>(() => ({request, close}), [request, close])
114+
115+
const withMfa = React.useMemo<WithMfa>(() => (operation) => withMfaRetry(operation, dialog), [dialog])
116+
117+
const initialValues = React.useMemo<{ code: string | null }>(() => ({code: null}), [requestId])
118+
119+
const [inputs, validate] = useForm<{ code: string | null }>({
120+
useInitialValidation: false,
121+
initialValues,
122+
validate: {
123+
code: (value) => !value ? "Enter a code from your authenticator app or a backup code" : null
124+
},
125+
onSubmit: (values) => {
126+
if (!values.code) return
127+
setState(prev => ({...prev, loading: true}))
128+
resolve({type: type as MfaType, value: values.code})
129+
}
130+
})
131+
132+
const onOpenChange = (open: boolean) => {
133+
if (open) return
134+
resolve(null)
135+
setState({open: false, error: undefined, loading: false})
136+
}
137+
138+
return <MfaContext.Provider value={withMfa}>
139+
{children}
140+
<InputDialog title={"Confirm it's you"}
141+
description={"This action requires multi-factor authentication. Enter a code from your authenticator app or one of your backup codes to continue."}
142+
open={state.open}
143+
onOpenChange={onOpenChange}>
144+
<Flex justify={"space-between"} align={"center"}>
145+
<Text size={"lg"} hierarchy={"primary"} display={"block"}>
146+
Confirm it's you
147+
</Text>
148+
<SegmentedControl type={"single"}
149+
value={type}
150+
tabIndex={-1}
151+
onValueChange={(next) => {
152+
setType(next as MfaType)
153+
inputs.getInputProps("code")?.formValidation?.setValue?.(null)
154+
}}>
155+
<SegmentedControlItem tabIndex={-1} w={"100%"} value={"TOTP"}>
156+
<Text>Authenticator</Text>
157+
</SegmentedControlItem>
158+
<SegmentedControlItem tabIndex={-1} w={"100%"} value={"BACKUP_CODE"}>
159+
<Text>Backup code</Text>
160+
</SegmentedControlItem>
161+
</SegmentedControl>
162+
</Flex>
163+
<Spacing spacing={"xs"}/>
164+
<Text size={"md"} hierarchy={"tertiary"}>
165+
This action requires multi-factor authentication. Enter a code from your authenticator app or one of your backup codes to continue.
166+
</Text>
167+
<Spacing spacing={"md"}/>
168+
<MfaInputComponent key={requestId}
169+
type={type as MfaType}
170+
autoFocus={true}
171+
disabled={state.loading}
172+
onComplete={() => validate()}
173+
{...inputs.getInputProps("code")}/>
174+
{state.error ? <InputMessage>{state.error}</InputMessage> : <></>}
175+
<Spacing spacing={"md"}/>
176+
<Button disabled={state.loading} color={"success"} w={"100%"} onClick={() => validate()}>
177+
{state.loading ? "Verifying..." : "Confirm"}
178+
</Button>
179+
</InputDialog>
180+
</MfaContext.Provider>
181+
}

0 commit comments

Comments
 (0)