Skip to content

Commit eabe414

Browse files
authored
Merge pull request #317 from code0-tech/feat/#304
2FA support
2 parents 1189fad + e3d5f7c commit eabe414

20 files changed

Lines changed: 1174 additions & 283 deletions

package-lock.json

Lines changed: 367 additions & 195 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"lodash": "^4.18.1",
4444
"next": "16.2.11",
4545
"prettier": "^3.8.1",
46+
"qrcode": "^1.5.4",
4647
"react": "19.2.6",
4748
"react-dom": "19.2.6",
4849
"react-hotkeys-hook": "^5.2.4",
@@ -56,6 +57,7 @@
5657
"@svgr/webpack": "^8.1.0",
5758
"@types/lodash": "^4.17.24",
5859
"@types/node": "^24.0.0",
60+
"@types/qrcode": "^1.5.6",
5961
"@types/react": "^19",
6062
"@types/react-dom": "^19",
6163
"babel-plugin-react-compiler": "^1.0.0",

src/app/(dashboard)/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
User
3030
} from "@code0-tech/sagittarius-graphql-types";
3131
import {ApplicationMiddlewareComponent} from "@edition/application/components/ApplicationMiddlewareComponent";
32+
import {MfaProviderComponent} from "@edition/user/components/MfaProviderComponent";
3233
import {FullScreen} from "@code0-tech/pictor/dist/components/fullscreen/FullScreen";
3334
import {ApplicationNavigationView} from "@edition/application/views/ApplicationNavigationView";
3435
import {FlowService} from "@edition/flow/services/Flow.service";
@@ -91,6 +92,7 @@ const ApplicationLayout: React.FC<ApplicationLayoutProps> = ({children, sidebar,
9192
return <ContextStoreProvider
9293
services={[application, user, organization, member, namespace, runtime, project, role, flow, functions, datatype, flowtype, module, ai]}>
9394
<ApplicationMiddlewareComponent>
95+
<MfaProviderComponent>
9496
<FullScreen bg={"var(--secondary)"}>
9597
<Layout p={1} showLayoutSplitter={false} layoutGap={32} leftContent={<ApplicationNavigationView/>}>
9698
<Layout showLayoutSplitter={false} layoutGap={32} leftContent={<div style={{
@@ -105,6 +107,7 @@ const ApplicationLayout: React.FC<ApplicationLayoutProps> = ({children, sidebar,
105107
</Layout>
106108
</Layout>
107109
</FullScreen>
110+
</MfaProviderComponent>
108111
</ApplicationMiddlewareComponent>
109112
</ContextStoreProvider>
110113
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client"
2+
3+
import React from "react";
4+
import {TextInput} from "@code0-tech/pictor";
5+
import {TextInputProps} from "@code0-tech/pictor/dist/components/form/TextInput";
6+
import {PinInput, PinInputField} from "@code0-tech/pictor/dist/components/form/PinInput";
7+
import {MfaType} from "@code0-tech/sagittarius-graphql-types";
8+
9+
const TOTP_LENGTH = 6
10+
11+
export interface MfaInputComponentProps extends Omit<TextInputProps, "title" | "description"> {
12+
type?: MfaType
13+
onComplete?: (value: string) => void
14+
}
15+
16+
export const MfaInputComponent: React.FC<MfaInputComponentProps> = (props) => {
17+
18+
const {type = "TOTP", onComplete, ...rest} = props
19+
20+
return type === "BACKUP_CODE" ? (
21+
<TextInput w={"100%"}
22+
title={"Backup code"}
23+
description={"Enter one of your saved backup codes."}
24+
placeholder={"Backup code"}
25+
{...rest}/>
26+
) : (
27+
<PinInput placeholder={"000000"}
28+
title={"Authentication code"}
29+
description={"Enter the 6-digit code from your authenticator app."}
30+
autoSubmit
31+
onAutoSubmit={onComplete}
32+
{...(rest as React.ComponentProps<typeof PinInput>)}>
33+
{Array.from({length: TOTP_LENGTH}).map((_, index) => (
34+
<PinInputField key={index}/>
35+
))}
36+
</PinInput>
37+
)
38+
}
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+
}

src/packages/ce/src/user/components/UserEditDialogComponent.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,18 @@ import {User, UsersUpdateInput} from "@code0-tech/sagittarius-graphql-types";
2626
import {UserService} from "@edition/user/services/User.service";
2727
import {useUserSession} from "@edition/user/hooks/User.session.hook";
2828
import {toast} from "@code0-tech/pictor/dist/components/toast/Toast";
29-
import {IconAt, IconBackground, IconLock, IconMail, IconSettings2, IconShieldLock} from "@tabler/icons-react";
29+
import {
30+
IconAt,
31+
IconBackground,
32+
IconLock,
33+
IconMail,
34+
IconSettings2,
35+
IconShield,
36+
IconShieldLock
37+
} from "@tabler/icons-react";
3038
import {UserSessionsDataTableComponent} from "@edition/user/components/UserSessionsDataTableComponent";
39+
import {useMfa} from "@edition/user/components/MfaProviderComponent";
40+
import {UserMfaView} from "@edition/user/views/UserMfaView";
3141
import {SettingDialog} from "@core/components/SettingDialog";
3242

3343
export interface UserEditDialogComponentProps {
@@ -42,6 +52,7 @@ export const UserEditDialogComponent: React.FC<UserEditDialogComponentProps> = (
4252

4353
const userService = useService(UserService)
4454
const userStore = useStore(UserService)
55+
const withMfa = useMfa()
4556
const [, startTransition] = React.useTransition()
4657

4758
const currentSession = useUserSession()
@@ -123,11 +134,12 @@ export const UserEditDialogComponent: React.FC<UserEditDialogComponentProps> = (
123134
}
124135

125136
startTransition(async () => {
126-
await userService.usersUpdate(payload).then(payload => {
127-
if (payload?.user && (payload?.errors?.length ?? 0) <= 0) {
128-
toast({title: "Updated user", color: "success"})
129-
}
130-
})
137+
// usersUpdate may require a fresh MFA confirmation. withMfa shows the step-up
138+
// dialog and retries with the mfa merged into the payload.
139+
const result = await withMfa((mfa) => userService.usersUpdate({...payload, ...(mfa ? {mfa} : {})}))
140+
if (result?.user && (result?.errors?.length ?? 0) <= 0) {
141+
toast({title: "Updated user", color: "success"})
142+
}
131143
})
132144
}
133145
})
@@ -153,10 +165,18 @@ export const UserEditDialogComponent: React.FC<UserEditDialogComponentProps> = (
153165
)}
154166
<TabTrigger value={"security"} w={"100%"} asChild>
155167
<Button paddingSize={"xxs"} variant={"none"} justify={"start"}>
156-
<IconSettings2 opacity={0} size={13}/>
168+
<IconShield size={13}/>
157169
<Text size={"md"}>Security</Text>
158170
</Button>
159171
</TabTrigger>
172+
{isSelf && (
173+
<TabTrigger value={"mfa"} w={"100%"} asChild>
174+
<Button paddingSize={"xxs"} variant={"none"} justify={"start"}>
175+
<IconSettings2 opacity={0} size={13}/>
176+
<Text size={"md"}>2-Step Verification</Text>
177+
</Button>
178+
</TabTrigger>
179+
)}
160180
<TabTrigger value={"sessions"} w={"100%"} asChild>
161181
<Button paddingSize={"xxs"} variant={"none"} justify={"start"}>
162182
<IconSettings2 opacity={0} size={13}/>
@@ -281,6 +301,7 @@ export const UserEditDialogComponent: React.FC<UserEditDialogComponentProps> = (
281301
onChange={() => validate("repeatPassword")}
282302
{...inputs.getInputProps("repeatPassword")}/>
283303
</TabContent>
304+
{isSelf ? <UserMfaView/> : <></>}
284305
<TabContent value={"sessions"}
285306
style={{
286307
overflow: "hidden",

0 commit comments

Comments
 (0)