Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ec12b4c
feat: add MFA mutations and update user queries for enhanced security…
nicosammito Jul 28, 2026
2237f6f
feat: add QrCodeComponent for generating QR codes with customizable size
nicosammito Jul 28, 2026
291354b
feat: add qrcode package and its TypeScript types for QR code generat…
nicosammito Jul 28, 2026
0a87b77
feat: add MFA input to user update mutation for enhanced security
nicosammito Jul 28, 2026
5fb1cff
feat: add MFA status fields to currentUser query for enhanced user se…
nicosammito Jul 28, 2026
2ef6212
feat: replace separator variable with inline JSX for improved readabi…
nicosammito Jul 28, 2026
e2120e8
feat: implement UserMfaView component for managing two-factor authent…
nicosammito Jul 28, 2026
1577995
feat: refactor UserRegistrationPage to use memoized initialValues for…
nicosammito Jul 28, 2026
9f06fa6
feat: integrate UserMfaView component into UserEditDialog for enhance…
nicosammito Jul 28, 2026
aad5c65
feat: add tabIndex to InputDialog for improved accessibility
nicosammito Jul 28, 2026
3edbbb0
feat: add MfaInputComponent for handling multi-factor authentication …
nicosammito Jul 28, 2026
889d04f
feat: implement MFA handling in UserLoginPage for enhanced security
nicosammito Jul 29, 2026
79e3006
feat: enhance user service with MFA support for user updates and secr…
nicosammito Jul 29, 2026
7a2be94
feat: integrate MfaProviderComponent into layout for multi-factor aut…
nicosammito Jul 30, 2026
ff7ea6a
feat: remove MFA retry logic from usersUpdate method for streamlined …
nicosammito Jul 30, 2026
875d39b
feat: integrate MFA handling in UserEditDialogComponent for user updates
nicosammito Jul 30, 2026
a2188e8
feat: add MfaProviderComponent for multi-factor authentication handling
nicosammito Jul 30, 2026
e3d5f7c
feat: enhance UserMfaView with dynamic issuer handling for TOTP and b…
nicosammito Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
562 changes: 367 additions & 195 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"lodash": "^4.18.1",
"next": "16.2.11",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hotkeys-hook": "^5.2.4",
Expand All @@ -56,6 +57,7 @@
"@svgr/webpack": "^8.1.0",
"@types/lodash": "^4.17.24",
"@types/node": "^24.0.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "^1.0.0",
Expand Down
3 changes: 3 additions & 0 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
User
} from "@code0-tech/sagittarius-graphql-types";
import {ApplicationMiddlewareComponent} from "@edition/application/components/ApplicationMiddlewareComponent";
import {MfaProviderComponent} from "@edition/user/components/MfaProviderComponent";
import {FullScreen} from "@code0-tech/pictor/dist/components/fullscreen/FullScreen";
import {ApplicationNavigationView} from "@edition/application/views/ApplicationNavigationView";
import {FlowService} from "@edition/flow/services/Flow.service";
Expand Down Expand Up @@ -91,6 +92,7 @@ const ApplicationLayout: React.FC<ApplicationLayoutProps> = ({children, sidebar,
return <ContextStoreProvider
services={[application, user, organization, member, namespace, runtime, project, role, flow, functions, datatype, flowtype, module, ai]}>
<ApplicationMiddlewareComponent>
<MfaProviderComponent>
<FullScreen bg={"var(--secondary)"}>
<Layout p={1} showLayoutSplitter={false} layoutGap={32} leftContent={<ApplicationNavigationView/>}>
<Layout showLayoutSplitter={false} layoutGap={32} leftContent={<div style={{
Expand All @@ -105,6 +107,7 @@ const ApplicationLayout: React.FC<ApplicationLayoutProps> = ({children, sidebar,
</Layout>
</Layout>
</FullScreen>
</MfaProviderComponent>
</ApplicationMiddlewareComponent>
</ContextStoreProvider>
}
Expand Down
38 changes: 38 additions & 0 deletions src/packages/ce/src/user/components/MfaInputComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client"

import React from "react";
import {TextInput} from "@code0-tech/pictor";
import {TextInputProps} from "@code0-tech/pictor/dist/components/form/TextInput";
import {PinInput, PinInputField} from "@code0-tech/pictor/dist/components/form/PinInput";
import {MfaType} from "@code0-tech/sagittarius-graphql-types";

const TOTP_LENGTH = 6

export interface MfaInputComponentProps extends Omit<TextInputProps, "title" | "description"> {
type?: MfaType
onComplete?: (value: string) => void
}

export const MfaInputComponent: React.FC<MfaInputComponentProps> = (props) => {

const {type = "TOTP", onComplete, ...rest} = props

return type === "BACKUP_CODE" ? (
<TextInput w={"100%"}
title={"Backup code"}
description={"Enter one of your saved backup codes."}
placeholder={"Backup code"}
{...rest}/>
) : (
<PinInput placeholder={"000000"}
title={"Authentication code"}
description={"Enter the 6-digit code from your authenticator app."}
autoSubmit
onAutoSubmit={onComplete}
{...(rest as React.ComponentProps<typeof PinInput>)}>
{Array.from({length: TOTP_LENGTH}).map((_, index) => (
<PinInputField key={index}/>
))}
</PinInput>
)
}
181 changes: 181 additions & 0 deletions src/packages/ce/src/user/components/MfaProviderComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"use client"

import React from "react";
import {
Button,
Flex,
InputMessage,
SegmentedControl,
SegmentedControlItem,
Spacing,
Text,
useForm
} from "@code0-tech/pictor";
import {MfaInput, MfaType} from "@code0-tech/sagittarius-graphql-types";
import {MfaInputComponent} from "@edition/user/components/MfaInputComponent";
import {InputDialog} from "@core/components/InputDialog";

// Error codes signalling that an operation needs a fresh second factor.
const MFA_TRIGGER_ERRORS = ["MFA_REQUIRED", "MFA_FAILED"]
// Codes returned when a supplied code was wrong — keep the dialog open to retry.
const MFA_RETRYABLE_ERRORS = ["MFA_REQUIRED", "MFA_FAILED", "WRONG_TOTP", "INVALID_TOTP_SECRET"]

interface MfaDialogOptions {
error?: string
}

// The dialog handle held below in React state and passed into withMfaRetry.
interface MfaDialog {
request: (options?: MfaDialogOptions) => Promise<MfaInput | null>
close: () => void
}

// Returned by useMfa(): wraps an operation with MFA step-up so callers only pass the operation.
export interface WithMfa {
<T extends PayloadWithErrors | undefined>(operation: (mfa?: MfaInput) => Promise<T>): Promise<T>
}

interface ErrorLike {
errorCode?: string | null
}

interface PayloadWithErrors {
errors?: (ErrorLike | null)[] | null
}

const hasError = (payload: PayloadWithErrors | undefined, codes: string[]): boolean =>
!!payload?.errors?.some(error => !!error?.errorCode && codes.includes(error.errorCode))

const requestAndRetry = async <T extends PayloadWithErrors | undefined>(
operation: (mfa?: MfaInput) => Promise<T>,
dialog: MfaDialog,
previous: T,
error?: string
): Promise<T> => {
const mfa = await dialog.request({error})
if (!mfa) return previous // user cancelled — surface the MFA-required payload

const result = await operation(mfa)
if (hasError(result, MFA_RETRYABLE_ERRORS)) {
return requestAndRetry(operation, dialog, result, "The code you entered is invalid. Please try again.")
}

dialog.close()
return result
}

const withMfaRetry = async <T extends PayloadWithErrors | undefined>(
operation: (mfa?: MfaInput) => Promise<T>,
dialog: MfaDialog
): Promise<T> => {
const result = await operation()
if (!hasError(result, MFA_TRIGGER_ERRORS)) return result
return requestAndRetry(operation, dialog, result)
}

const MfaContext = React.createContext<WithMfa | null>(null)

export const useMfa = (): WithMfa => {
const withMfa = React.useContext(MfaContext)
if (!withMfa) throw new Error("useMfa must be used within an MfaProviderComponent")
return withMfa
}

export const MfaProviderComponent: React.FC<{ children: React.ReactNode }> = ({children}) => {

const [state, setState] = React.useState<{ open: boolean; error?: string; loading: boolean }>({
open: false,
loading: false
})

const [requestId, setRequestId] = React.useState(0)
const resolverRef = React.useRef<((value: MfaInput | null) => void) | null>(null)
const [type, setType] = React.useState("TOTP")

const resolve = React.useCallback((value: MfaInput | null) => {
const resolver = resolverRef.current
resolverRef.current = null
resolver?.(value)
}, [])

const request = React.useCallback((options: MfaDialogOptions = {}) =>
new Promise<MfaInput | null>((resolve) => {
resolverRef.current?.(null)
resolverRef.current = resolve
setRequestId(id => id + 1)
setState({open: true, error: options.error, loading: false})
}), [])

const close = React.useCallback(() => {
setState({open: false, error: undefined, loading: false})
}, [])

const dialog = React.useMemo<MfaDialog>(() => ({request, close}), [request, close])

const withMfa = React.useMemo<WithMfa>(() => (operation) => withMfaRetry(operation, dialog), [dialog])

const initialValues = React.useMemo<{ code: string | null }>(() => ({code: null}), [requestId])

const [inputs, validate] = useForm<{ code: string | null }>({
useInitialValidation: false,
initialValues,
validate: {
code: (value) => !value ? "Enter a code from your authenticator app or a backup code" : null
},
onSubmit: (values) => {
if (!values.code) return
setState(prev => ({...prev, loading: true}))
resolve({type: type as MfaType, value: values.code})
}
})

const onOpenChange = (open: boolean) => {
if (open) return
resolve(null)
setState({open: false, error: undefined, loading: false})
}

return <MfaContext.Provider value={withMfa}>
{children}
<InputDialog title={"Confirm it's you"}
description={"This action requires multi-factor authentication. Enter a code from your authenticator app or one of your backup codes to continue."}
open={state.open}
onOpenChange={onOpenChange}>
<Flex justify={"space-between"} align={"center"}>
<Text size={"lg"} hierarchy={"primary"} display={"block"}>
Confirm it's you
</Text>
<SegmentedControl type={"single"}
value={type}
tabIndex={-1}
onValueChange={(next) => {
setType(next as MfaType)
inputs.getInputProps("code")?.formValidation?.setValue?.(null)
}}>
<SegmentedControlItem tabIndex={-1} w={"100%"} value={"TOTP"}>
<Text>Authenticator</Text>
</SegmentedControlItem>
<SegmentedControlItem tabIndex={-1} w={"100%"} value={"BACKUP_CODE"}>
<Text>Backup code</Text>
</SegmentedControlItem>
</SegmentedControl>
</Flex>
<Spacing spacing={"xs"}/>
<Text size={"md"} hierarchy={"tertiary"}>
This action requires multi-factor authentication. Enter a code from your authenticator app or one of your backup codes to continue.
</Text>
<Spacing spacing={"md"}/>
<MfaInputComponent key={requestId}
type={type as MfaType}
autoFocus={true}
disabled={state.loading}
onComplete={() => validate()}
{...inputs.getInputProps("code")}/>
{state.error ? <InputMessage>{state.error}</InputMessage> : <></>}
<Spacing spacing={"md"}/>
<Button disabled={state.loading} color={"success"} w={"100%"} onClick={() => validate()}>
{state.loading ? "Verifying..." : "Confirm"}
</Button>
</InputDialog>
</MfaContext.Provider>
}
35 changes: 28 additions & 7 deletions src/packages/ce/src/user/components/UserEditDialogComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,18 @@ import {User, UsersUpdateInput} from "@code0-tech/sagittarius-graphql-types";
import {UserService} from "@edition/user/services/User.service";
import {useUserSession} from "@edition/user/hooks/User.session.hook";
import {toast} from "@code0-tech/pictor/dist/components/toast/Toast";
import {IconAt, IconBackground, IconLock, IconMail, IconSettings2, IconShieldLock} from "@tabler/icons-react";
import {
IconAt,
IconBackground,
IconLock,
IconMail,
IconSettings2,
IconShield,
IconShieldLock
} from "@tabler/icons-react";
import {UserSessionsDataTableComponent} from "@edition/user/components/UserSessionsDataTableComponent";
import {useMfa} from "@edition/user/components/MfaProviderComponent";
import {UserMfaView} from "@edition/user/views/UserMfaView";
import {SettingDialog} from "@core/components/SettingDialog";

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

const userService = useService(UserService)
const userStore = useStore(UserService)
const withMfa = useMfa()
const [, startTransition] = React.useTransition()

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

startTransition(async () => {
await userService.usersUpdate(payload).then(payload => {
if (payload?.user && (payload?.errors?.length ?? 0) <= 0) {
toast({title: "Updated user", color: "success"})
}
})
// usersUpdate may require a fresh MFA confirmation. withMfa shows the step-up
// dialog and retries with the mfa merged into the payload.
const result = await withMfa((mfa) => userService.usersUpdate({...payload, ...(mfa ? {mfa} : {})}))
if (result?.user && (result?.errors?.length ?? 0) <= 0) {
toast({title: "Updated user", color: "success"})
}
})
}
})
Expand All @@ -153,10 +165,18 @@ export const UserEditDialogComponent: React.FC<UserEditDialogComponentProps> = (
)}
<TabTrigger value={"security"} w={"100%"} asChild>
<Button paddingSize={"xxs"} variant={"none"} justify={"start"}>
<IconSettings2 opacity={0} size={13}/>
<IconShield size={13}/>
<Text size={"md"}>Security</Text>
</Button>
</TabTrigger>
{isSelf && (
<TabTrigger value={"mfa"} w={"100%"} asChild>
<Button paddingSize={"xxs"} variant={"none"} justify={"start"}>
<IconSettings2 opacity={0} size={13}/>
<Text size={"md"}>2-Step Verification</Text>
</Button>
</TabTrigger>
)}
<TabTrigger value={"sessions"} w={"100%"} asChild>
<Button paddingSize={"xxs"} variant={"none"} justify={"start"}>
<IconSettings2 opacity={0} size={13}/>
Expand Down Expand Up @@ -281,6 +301,7 @@ export const UserEditDialogComponent: React.FC<UserEditDialogComponentProps> = (
onChange={() => validate("repeatPassword")}
{...inputs.getInputProps("repeatPassword")}/>
</TabContent>
{isSelf ? <UserMfaView/> : <></>}
<TabContent value={"sessions"}
style={{
overflow: "hidden",
Expand Down
Loading