diff --git a/src/components/modals/TwoFactorDisableModal.tsx b/src/components/modals/TwoFactorDisableModal.tsx new file mode 100644 index 000000000..859d2df75 --- /dev/null +++ b/src/components/modals/TwoFactorDisableModal.tsx @@ -0,0 +1,64 @@ +'use client'; +import { Button, Column, Dialog, Modal, Row, Text } from '@umami/react-zen'; +import { Form, FormButtons, FormField, FormSubmitButton, PasswordField } from '@umami/react-zen'; +import { useState } from 'react'; +import { OtpInput } from '@/components/common/OtpInput'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +interface TwoFactorDisableModalProps { + onClose: () => void; + onSuccess: () => void; +} + +export function TwoFactorDisableModal({ onClose, onSuccess }: TwoFactorDisableModalProps) { + const { t, labels, messages, getErrorMessage } = useMessages(); + const [otpValue, setOtpValue] = useState(''); + const { mutateAsync: disable, error, isPending, reset } = useUpdateQuery('/2fa/disable'); + + const handleSubmit = async (data: any) => { + await disable({ password: data.password, token: otpValue }, { onSuccess }).catch(() => {}); + }; + + const resetError = () => { + if (error) reset(); + }; + + return ( + + + {({ close }) => ( + + {t(messages.twoFactorDisableDescription)} + + + + + + {t(labels.twoFactorEnterCode)} + { + setOtpValue(val); + resetError(); + }} + disabled={isPending} + /> + + + + {t(labels.cancel)} + + + {t(labels.twoFactorDisable)} + + + + + )} + + + ); +} diff --git a/src/components/modals/TwoFactorSetupModal.module.css b/src/components/modals/TwoFactorSetupModal.module.css new file mode 100644 index 000000000..aa2d0071b --- /dev/null +++ b/src/components/modals/TwoFactorSetupModal.module.css @@ -0,0 +1,9 @@ +.twoFactorSetupModal { + width: 700px; + max-width: 95vw; +} + +.qrCodeImage { + width: 33%; + aspect-ratio: 1; +} diff --git a/src/components/modals/TwoFactorSetupModal.tsx b/src/components/modals/TwoFactorSetupModal.tsx new file mode 100644 index 000000000..85e9b9512 --- /dev/null +++ b/src/components/modals/TwoFactorSetupModal.tsx @@ -0,0 +1,211 @@ +'use client'; +import { + AlertBanner, + Box, + Button, + Code, + Column, + Dialog, + Icon, + Image, + Modal, + Row, + Tag, + TagGroup, + Text, +} from '@umami/react-zen'; +import { useQueryClient } from '@tanstack/react-query'; +import { ReactNode, useEffect, useState } from 'react'; +import { OtpInput } from '@/components/common/OtpInput'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { TwoFactorSuccessModal } from './TwoFactorSuccessModal'; +import styles from './TwoFactorSetupModal.module.css'; +import { LucideCopy } from 'lucide-react'; + +function Step({ + tag, + title, + details, + children, +}: { + tag: string; + title: string; + details: string; + children: ReactNode; +}) { + return ( + + + + + {tag} + + {title} + + {details} + + {children} + + ); +} + +interface TwoFactorSetupModalProps { + required: boolean; + onClose?: () => void; +} + +export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalProps) { + const { t, labels, messages } = useMessages(); + const [qrCodeDataUrl, setQrCodeDataUrl] = useState(null); + const [manualKey, setManualKey] = useState(null); + const [otpValue, setOtpValue] = useState(''); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [backupCodes, setBackupCodes] = useState(null); + + const queryClient = useQueryClient(); + const { mutateAsync: initiate } = useUpdateQuery('/2fa/setup/initiate'); + const { mutateAsync: confirm, isPending: isConfirming } = useUpdateQuery('/2fa/setup/confirm'); + const { mutateAsync: cancel } = useUpdateQuery('/2fa/setup/cancel'); + + useEffect(() => { + initiate({}) + .then((data: any) => { + setQrCodeDataUrl(data.qrCodeDataUrl); + setManualKey(data.manualKey); + }) + .catch(() => setError(t(messages.error))); + }, []); + + useEffect(() => { + console.log({ lengthTracked: otpValue.length }); + }, [otpValue.length]); + + const handleConfirm = async (value?: string) => { + const token = value ?? otpValue; + if (token.length !== 6) return; + setError(null); + try { + const data: any = await confirm({ token }); + setBackupCodes(data.backupCodes); + } catch (err: any) { + setError(err.message || t(messages.error)); + } + }; + + const handleCancel = async () => { + await cancel({}).catch(() => {}); + onClose?.(); + }; + + const handleCopy = () => { + if (manualKey) { + navigator.clipboard.writeText(manualKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (backupCodes) { + return ( + { + queryClient.invalidateQueries({ queryKey: ['2fa-status'] }); + onClose?.(); + }} + /> + ); + } + + const preventDismiss = required ? () => {} : undefined; + + return ( + + + {({ close }) => ( + + + {required && t(messages.twoFactorSetupRequiredDescription) + ' '} + {t(messages.twoFactorSetupDescription)} + + + {/* Step 1 - Scan QR Code */} + + + + {qrCodeDataUrl && ( + + )} + + {t(labels.twoFactorCantScan)} + + {t(labels.twoFactorManualEntry)} + {manualKey} + + + + + + {copied ? t(labels.twoFactorCodeCopied) : t(labels.twoFactorCopyCode)} + + + + + + + + + {/* Step 2 - Enter Verification Code */} + + + + {t(labels.twoFactorEnterCode)} + + { + setOtpValue(val); + if (error) setError(null); + }} + onComplete={handleConfirm} + disabled={isConfirming} + /> + + + + {error && } + + + {!required && ( + + {t(labels.cancel)} + + )} + handleConfirm()} + isDisabled={otpValue.length !== 6 || isConfirming || !!error} + > + {t(labels.confirm)} + + + + )} + + + ); +} diff --git a/src/components/modals/TwoFactorSuccessModal.tsx b/src/components/modals/TwoFactorSuccessModal.tsx new file mode 100644 index 000000000..51f2dcba4 --- /dev/null +++ b/src/components/modals/TwoFactorSuccessModal.tsx @@ -0,0 +1,88 @@ +'use client'; +import { Button, Column, Dialog, Modal, Row, Text } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; + +interface TwoFactorSuccessModalProps { + backupCodes: string[]; + onClose: () => void; +} + +export function TwoFactorSuccessModal({ backupCodes, onClose }: TwoFactorSuccessModalProps) { + const { t, labels, messages } = useMessages(); + const [saved, setSaved] = useState(false); + + const handleDownload = () => { + const content = [ + 'Umami - Two-factor authentication backup codes', + 'Keep these codes safe. Each code can only be used once.', + '', + ...backupCodes, + ].join('\n'); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'umami-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + {}}> + + {() => ( + + {t(messages.twoFactorEnabledDescription)} + + + {t(labels.twoFactorSaveBackupCodes)} + {t(messages.twoFactorBackupDescription)} + + + {backupCodes.map((code, i) => ( + + {code} + + ))} + + + + + {t(labels.twoFactorDownloadCodes)} + + + + + + setSaved(e.target.checked)} + /> + + {t(labels.twoFactorSavedCodes)} + + + + + + {t(labels.close)} + + + + )} + + + ); +}
{manualKey}
+ {code} +