diff --git a/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeDash.tsx b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeDash.tsx new file mode 100644 index 0000000000..1771c0513a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeDash.tsx @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +const StyledDashContainer = styled.div` + display: flex; + width: ${({ theme }) => theme.spacing(2)}; + justify-content: center; + align-items: center; +`; + +const StyledDash = styled.div` + background-color: ${({ theme }) => theme.font.color.light}; + height: ${({ theme }) => theme.spacing(0.4)}; + width: ${({ theme }) => theme.spacing(1.5)}; +`; + +export const TwoFactorAuthenticationVerificationCodeDash = () => { + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeSlot.tsx b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeSlot.tsx new file mode 100644 index 0000000000..9579037924 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeSlot.tsx @@ -0,0 +1,91 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { type SlotProps } from 'input-otp'; + +const StyledSlot = styled.div<{ isActive: boolean }>` + position: relative; + width: 24px; + height: 32px; + font-size: ${({ theme }) => theme.font.size.md}; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + + .group:hover &, + .group:focus-within & { + border-color: ${({ theme }) => theme.border.color.medium}; + } + + outline: 0; + outline-color: ${({ theme }) => theme.border.color.medium}; + + ${({ isActive, theme }) => + isActive && + css` + outline-width: 1px; + outline-style: solid; + outline-color: ${theme.border.color.strong}; + `} +`; + +const StyledCaretContainer = styled.div` + align-items: center; + animation: caret-blink 1s steps(2, start) infinite; + display: flex; + inset: 0; + justify-content: center; + pointer-events: none; + position: absolute; + + @keyframes caret-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } + } +`; + +const StyledPlaceholderChar = styled.div` + opacity: 0.2; +`; + +const StyledInputChar = styled.div` + opacity: 1; +`; + +const StyledCaret = styled.div` + width: 1px; + height: 20px; + background-color: ${({ theme }) => theme.font.color.primary}; +`; + +type TwoFactorAuthenticationVerificationCodeSlotProps = SlotProps; + +export const TwoFactorAuthenticationVerificationCodeSlot = ({ + char, + hasFakeCaret, + isActive, + placeholderChar, +}: TwoFactorAuthenticationVerificationCodeSlotProps) => { + return ( + + {char ? ( + {char} + ) : ( + {placeholderChar ?? 'X'} + )} + {hasFakeCaret && ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings.tsx b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings.tsx index ea38c82994..6ab4716848 100644 --- a/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings.tsx +++ b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings.tsx @@ -1,16 +1,9 @@ -import { useMutation } from '@apollo/client'; -import { css } from '@emotion/react'; import styled from '@emotion/styled'; -import { useLingui } from '@lingui/react/macro'; -import { OTPInput, type SlotProps } from 'input-otp'; -import { useState } from 'react'; -import { Controller, useForm, useFormContext } from 'react-hook-form'; +import { OTPInput } from 'input-otp'; +import { Controller, useFormContext } from 'react-hook-form'; -import { VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER } from '@/settings/two-factor-authentication/graphql/mutations/verifyTwoFactorAuthenticationMethod'; -import { SettingsPath } from '@/types/SettingsPath'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { TwoFactorAuthenticationVerificationCodeDash } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeDash'; +import { TwoFactorAuthenticationVerificationCodeSlot } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeSlot'; // OTP Form Types type OTPFormValues = { @@ -19,7 +12,7 @@ type OTPFormValues = { const StyledOTPContainer = styled.div` display: flex; - + gap: ${({ theme }) => theme.spacing(1)}; margin-bottom: ${({ theme }) => theme.spacing(8)}; &:has(:disabled) { @@ -27,196 +20,7 @@ const StyledOTPContainer = styled.div` } `; -const StyledSlotGroup = styled.div` - display: flex; -`; - -const StyledSlot = styled.div<{ isActive: boolean }>` - position: relative; - width: 2.5rem; - height: 3.5rem; - font-size: 2rem; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.3s; - border-top: 1px solid ${({ theme }) => theme.border.color.medium}; - border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; - border-right: 1px solid ${({ theme }) => theme.border.color.medium}; - - &:first-of-type { - border-left: 1px solid ${({ theme }) => theme.border.color.medium}; - border-top-left-radius: 0.375rem; - border-bottom-left-radius: 0.375rem; - } - - &:last-of-type { - border-top-right-radius: 0.375rem; - border-bottom-right-radius: 0.375rem; - } - - .group:hover &, - .group:focus-within & { - border-color: ${({ theme }) => theme.border.color.medium}; - } - - outline: 0; - outline-color: ${({ theme }) => theme.border.color.medium}; - - ${({ isActive, theme }) => - isActive && - css` - outline-width: 1px; - outline-style: solid; - outline-color: ${theme.border.color.strong}; - `} -`; - -const StyledPlaceholderChar = styled.div` - .group:has(input[data-input-otp-placeholder-shown]) & { - opacity: 0.2; - } -`; - -const StyledCaretContainer = styled.div` - align-items: center; - animation: caret-blink 1s steps(2, start) infinite; - display: flex; - inset: 0; - justify-content: center; - pointer-events: none; - position: absolute; - - @keyframes caret-blink { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0; - } - } -`; - -const StyledCaret = styled.div` - width: 1px; - height: 2rem; - background-color: ${({ theme }) => theme.font.color.primary}; -`; - -const StyledDashContainer = styled.div` - display: flex; - width: 2.5rem; - justify-content: center; - align-items: center; -`; - -const StyledDash = styled.div` - background-color: ${({ theme }) => theme.font.color.tertiary}; - border-radius: 9999px; - height: 0.25rem; - width: 0.75rem; -`; - -const FakeCaret = () => { - return ( - - - - ); -}; - -const FakeDash = () => { - return ( - - - - ); -}; - -export const Slot = (props: SlotProps) => { - return ( - - - {props.char ?? props.placeholderChar} - - {props.hasFakeCaret && } - - ); -}; - -export const useTwoFactorVerificationForSettings = () => { - const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); - const navigate = useNavigateSettings(); - const { t } = useLingui(); - const [isLoading, setIsLoading] = useState(false); - const { loadCurrentUser } = useLoadCurrentUser(); - - const [verifyTwoFactorAuthenticationMethod] = useMutation( - VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER, - ); - - const formConfig = useForm({ - mode: 'onChange', - defaultValues: { - otp: '', - }, - }); - - const { isSubmitting } = formConfig.formState; - const otpValue = formConfig.watch('otp'); - const canSave = !isSubmitting && otpValue?.length === 6; - - const handleVerificationSuccess = async () => { - enqueueSuccessSnackBar({ - message: t`Two-factor authentication setup completed successfully!`, - }); - - // Reload current user to refresh 2FA status - await loadCurrentUser(); - - // Navigate back to profile page - navigate(SettingsPath.ProfilePage); - }; - - const handleSave = async (values: OTPFormValues) => { - try { - setIsLoading(true); - - await verifyTwoFactorAuthenticationMethod({ - variables: { - otp: values.otp, - }, - }); - - await handleVerificationSuccess(); - } catch { - enqueueErrorSnackBar({ - message: t`Invalid verification code. Please try again.`, - }); - } finally { - setIsLoading(false); - } - }; - - const handleCancel = () => { - // Reset form and navigate back to profile page - formConfig.reset(); - navigate(SettingsPath.ProfilePage); - }; - - return { - formConfig, - isLoading, - canSave, - isSubmitting, - handleSave, - handleCancel, - }; -}; - export const TwoFactorAuthenticationVerificationForSettings = () => { - // Use the form context from the parent instead of creating a new form instance const formContext = useFormContext(); return ( @@ -231,29 +35,25 @@ export const TwoFactorAuthenticationVerificationForSettings = () => { value={value} render={({ slots }) => ( - - {slots.slice(0, 3).map((slot, idx) => ( - - ))} - - - - {slots.slice(3).map((slot, idx) => ( - - ))} - + {slots.slice(0, 3).map((slot, idx) => ( + + ))} + + {slots.slice(3).map((slot, idx) => ( + + ))} )} /> diff --git a/packages/twenty-front/src/modules/settings/two-factor-authentication/hooks/useTwoFactorVerificationForSettings.ts b/packages/twenty-front/src/modules/settings/two-factor-authentication/hooks/useTwoFactorVerificationForSettings.ts new file mode 100644 index 0000000000..b6e31af495 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/two-factor-authentication/hooks/useTwoFactorVerificationForSettings.ts @@ -0,0 +1,80 @@ +import { type OTPFormValues } from '@/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm'; +import { VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER } from '@/settings/two-factor-authentication/graphql/mutations/verifyTwoFactorAuthenticationMethod'; +import { SettingsPath } from '@/types/SettingsPath'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; +import { useMutation } from '@apollo/client'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; + +export const useTwoFactorVerificationForSettings = () => { + const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); + const navigate = useNavigateSettings(); + const { t } = useLingui(); + const [isLoading, setIsLoading] = useState(false); + const { loadCurrentUser } = useLoadCurrentUser(); + + const [verifyTwoFactorAuthenticationMethod] = useMutation( + VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER, + ); + + const formConfig = useForm({ + mode: 'onChange', + defaultValues: { + otp: '', + }, + }); + + const { isSubmitting } = formConfig.formState; + const otpValue = formConfig.watch('otp'); + const canSave = !isSubmitting && otpValue?.length === 6; + + const handleVerificationSuccess = async () => { + enqueueSuccessSnackBar({ + message: t`Two-factor authentication setup completed successfully!`, + }); + + // Reload current user to refresh 2FA status + await loadCurrentUser(); + + // Navigate back to profile page + navigate(SettingsPath.ProfilePage); + }; + + const handleSave = async (values: OTPFormValues) => { + try { + setIsLoading(true); + + await verifyTwoFactorAuthenticationMethod({ + variables: { + otp: values.otp, + }, + }); + + await handleVerificationSuccess(); + } catch { + enqueueErrorSnackBar({ + message: t`Invalid verification code. Please try again.`, + }); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + // Reset form and navigate back to profile page + formConfig.reset(); + navigate(SettingsPath.ProfilePage); + }; + + return { + formConfig, + isLoading, + canSave, + isSubmitting, + handleSave, + handleCancel, + }; +}; diff --git a/packages/twenty-front/src/pages/settings/SettingsProfile.tsx b/packages/twenty-front/src/pages/settings/SettingsProfile.tsx index c6bd180c3e..8b4f00b26f 100644 --- a/packages/twenty-front/src/pages/settings/SettingsProfile.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsProfile.tsx @@ -80,7 +80,7 @@ export const SettingsProfile = () => { has2FAMethod ? ( ) : ( - + ) } /> diff --git a/packages/twenty-front/src/pages/settings/SettingsTwoFactorAuthenticationMethod.tsx b/packages/twenty-front/src/pages/settings/SettingsTwoFactorAuthenticationMethod.tsx index 21eab7038d..9f7a35b39e 100644 --- a/packages/twenty-front/src/pages/settings/SettingsTwoFactorAuthenticationMethod.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsTwoFactorAuthenticationMethod.tsx @@ -9,11 +9,9 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { DeleteTwoFactorAuthentication } from '@/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod'; import { TwoFactorAuthenticationSetupForSettingsEffect } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationSetupForSettingsEffect'; -import { - TwoFactorAuthenticationVerificationForSettings, - useTwoFactorVerificationForSettings, -} from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings'; +import { TwoFactorAuthenticationVerificationForSettings } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings'; import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication'; +import { useTwoFactorVerificationForSettings } from '@/settings/two-factor-authentication/hooks/useTwoFactorVerificationForSettings'; import { extractSecretFromOtpUri } from '@/settings/two-factor-authentication/utils/extractSecretFromOtpUri'; import { SettingsPath } from '@/types/SettingsPath'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -26,40 +24,55 @@ import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; const StyledQRCodeContainer = styled.div` margin: ${({ theme }) => theme.spacing(4)} 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${({ theme }) => theme.spacing(3)}; `; -const StyledInstructions = styled.div` +const StyledQRCodeWrapper = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + justify-content: center; + padding: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledQRCode = styled(QRCode)` + height: 137px; + width: 137px; +`; + +const StyledCopySetupKeyText = styled.div` color: ${({ theme }) => theme.font.color.tertiary}; font-size: ${({ theme }) => theme.font.size.sm}; - margin-bottom: ${({ theme }) => theme.spacing(4)}; - max-width: 400px; -`; - -const StyledDivider = styled.div` - width: 100%; - height: 1px; - background-color: ${({ theme }) => theme.border.color.light}; - margin: ${({ theme }) => theme.spacing(6)} 0; + text-align: left; + line-height: 1.5; `; const StyledCopySetupKeyLink = styled.button` background: none; border: none; - color: ${({ theme }) => theme.font.color.secondary}; + color: ${({ theme }) => theme.font.color.tertiary}; cursor: pointer; - display: flex; - align-items: center; - gap: ${({ theme }) => theme.spacing(1)}; + display: inline; font-size: ${({ theme }) => theme.font.size.sm}; - margin-top: ${({ theme }) => theme.spacing(2)}; padding: 0; text-decoration: underline; + margin-left: 0; &:hover { - color: ${({ theme }) => theme.font.color.primary}; + color: ${({ theme }) => theme.font.color.secondary}; } `; +const StyledDivider = styled.div` + margin: ${({ theme }) => theme.spacing(6)} 0; + width: 100%; +`; + export const SettingsTwoFactorAuthenticationMethod = () => { const { t } = useLingui(); const theme = useTheme(); @@ -133,35 +146,34 @@ export const SettingsTwoFactorAuthenticationMethod = () => { ) : (
- - - - - Use an authenticator app like Google Authenticator, Authy, or - Microsoft Authenticator to scan this QR code. - - + - {!qrCode ? : } - {qrCode && ( - - - Copy Setup Key - + {!qrCode ? ( + + ) : ( + <> + + + + + Can't scan? Copy the{' '} + + setup key + + + )} - - - - - Enter the 6-digit verification code from your authenticator - app to complete the setup. - - - +
)} diff --git a/packages/twenty-ui/src/display/typography/components/H2Title.tsx b/packages/twenty-ui/src/display/typography/components/H2Title.tsx index f02053f4fe..022a967e59 100644 --- a/packages/twenty-ui/src/display/typography/components/H2Title.tsx +++ b/packages/twenty-ui/src/display/typography/components/H2Title.tsx @@ -50,7 +50,7 @@ export const H2Title = ({