diff --git a/apps/platform/trpc/routers/authRouter/recoveryRouter.ts b/apps/platform/trpc/routers/authRouter/recoveryRouter.ts index 00c61806..17e95125 100644 --- a/apps/platform/trpc/routers/authRouter/recoveryRouter.ts +++ b/apps/platform/trpc/routers/authRouter/recoveryRouter.ts @@ -3,16 +3,25 @@ import { Argon2id } from 'oslo/password'; import { router, publicRateLimitedProcedure } from '../../trpc'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; -import { nanoIdToken, zodSchemas } from '@u22n/utils'; +import { + nanoIdToken, + strongPasswordSchema, + typeIdValidator, + zodSchemas +} from '@u22n/utils'; import { TRPCError } from '@trpc/server'; import { createLuciaSessionCookie } from '../../../utils/session'; -import { decodeHex } from 'oslo/encoding'; -import { TOTPController } from 'oslo/otp'; -import { setCookie } from 'hono/cookie'; +import { decodeHex, encodeHex } from 'oslo/encoding'; +import { TOTPController, createTOTPKeyURI } from 'oslo/otp'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { env } from '../../../env'; import { storage } from '../../../storage'; +import { ms } from 'itty-time'; export const recoveryRouter = router({ + /** + * @deprecated use `getRecoveryVerificationToken` instead + */ recoverAccount: publicRateLimitedProcedure.recoverAccount .input( z.object({ @@ -163,5 +172,253 @@ export const recoveryRouter = router({ message: 'Something went wrong, you should never see this message. Please report to team immediately.' }); + }), + getRecoveryVerificationToken: publicRateLimitedProcedure.recoverAccount + .input( + z + .object({ + username: zodSchemas.username(2), + recoveryCode: zodSchemas.nanoIdToken() + }) + .and( + z.union([ + z.object({ password: z.string().min(8) }), + z.object({ twoFactorCode: z.string().min(6).max(6) }) + ]) + ) + ) + .query(async ({ input, ctx }) => { + const { db } = ctx; + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.username, input.username), + columns: { + id: true, + publicId: true, + username: true, + passwordHash: true, + twoFactorSecret: true, + twoFactorEnabled: true, + recoveryCode: true + } + }); + + if ( + !account || + !account.recoveryCode || + !account.passwordHash || + !account.twoFactorSecret + ) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'Either you provided a wrong username or recovery is not enabled for this account' + }); + } + + const isRecoveryCodeValid = await new Argon2id().verify( + account.recoveryCode, + input.recoveryCode + ); + + if (!isRecoveryCodeValid) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid Credentials' + }); + } + + let resetting: 'password' | '2fa' | null = null; + + if ('password' in input) { + const validPassword = await new Argon2id().verify( + account.passwordHash, + input.password + ); + + if (!validPassword) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid Credentials' + }); + } + + resetting = '2fa'; + } + + if ('twoFactorCode' in input) { + const secret = decodeHex(account.twoFactorSecret!); + const otpValid = await new TOTPController().verify( + input.twoFactorCode, + secret + ); + + if (!otpValid) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid Credentials' + }); + } + resetting = 'password'; + } + + if (!resetting) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Password or 2FA code required' + }); + } + + const resetToken = nanoIdToken(); + await storage.auth.setItem( + `reset-token:${resetting}:${account.publicId}`, + resetToken + ); + setCookie(ctx.event, `reset-token_${resetting}`, resetToken, { + maxAge: ms('5 minutes'), + httpOnly: true, + domain: env.PRIMARY_DOMAIN, + sameSite: 'Lax', + secure: env.NODE_ENV === 'production' + }); + + // If it is a 2FA reset, return the new URI too + if (resetting === '2fa') { + const newSecret = crypto.getRandomValues(new Uint8Array(20)); + const uri = createTOTPKeyURI('UnInbox.com', input.username, newSecret); + const hexSecret = encodeHex(newSecret); + await storage.auth.setItem( + `2fa-reset-secret:${account.publicId}`, + hexSecret + ); + return { resetting, accountPublicId: account.publicId, uri }; + } else { + return { resetting, accountPublicId: account.publicId }; + } + }), + resetPassword: publicRateLimitedProcedure.completeRecovery + .input( + z.object({ + accountPublicId: typeIdValidator('account'), + newPassword: strongPasswordSchema + }) + ) + .mutation(async ({ input, ctx }) => { + const { db, event } = ctx; + + const resetToken = getCookie(event, 'reset-token_password'); + const storedResetToken = await storage.auth.getItem( + `reset-token:password:${input.accountPublicId}` + ); + + if (!resetToken || !storedResetToken || resetToken !== storedResetToken) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid reset token' + }); + } + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.publicId, input.accountPublicId), + columns: { + id: true + } + }); + + if (!account) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Account not found' + }); + } + + const passwordHash = await new Argon2id().hash(input.newPassword); + await db + .update(accounts) + .set({ + passwordHash, + recoveryCode: null + }) + .where(eq(accounts.id, account.id)); + + await storage.auth.removeItem( + `reset-token:password:${input.accountPublicId}` + ); + + deleteCookie(event, 'reset-token_password'); + + return { success: true }; + }), + resetTwoFactor: publicRateLimitedProcedure.completeRecovery + .input( + z.object({ + accountPublicId: typeIdValidator('account'), + twoFactorCode: z.string().min(6).max(6) + }) + ) + .mutation(async ({ input, ctx }) => { + const { db, event } = ctx; + + const resetToken = getCookie(event, 'reset-token_2fa'); + const storedResetToken = await storage.auth.getItem( + `reset-token:2fa:${input.accountPublicId}` + ); + + if (!resetToken || !storedResetToken || resetToken !== storedResetToken) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid reset token' + }); + } + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.publicId, input.accountPublicId), + columns: { + id: true + } + }); + + if (!account) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Account not found' + }); + } + + const storedSecret = await storage.auth.getItem( + `2fa-reset-secret:${input.accountPublicId}` + ); + if (!storedSecret) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '2FA Secret not found, please try again after some time' + }); + } + + const secret = decodeHex(storedSecret); + const isValid = await new TOTPController().verify( + input.twoFactorCode, + secret + ); + if (!isValid) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '2FA code is not valid' + }); + } + + await db.update(accounts).set({ + twoFactorEnabled: true, + twoFactorSecret: storedSecret, + recoveryCode: null + }); + + await storage.auth.removeItem( + `2fa-reset-secret:${input.accountPublicId}` + ); + await storage.auth.removeItem(`reset-token:2fa:${input.accountPublicId}`); + deleteCookie(event, 'reset-token_2fa'); + + return { success: true }; }) }); diff --git a/apps/platform/trpc/trpc.ts b/apps/platform/trpc/trpc.ts index 40e30317..5cc31cd6 100644 --- a/apps/platform/trpc/trpc.ts +++ b/apps/platform/trpc/trpc.ts @@ -55,6 +55,7 @@ const publicRateLimits = { signUpWithPassword: [10, '1h'], signInWithPassword: [20, '1h'], recoverAccount: [10, '1h'], + completeRecovery: [20, '1h'], validateInvite: [10, '1h'] } satisfies Record; diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 3d46d71e..b5833d9b 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -34,7 +34,8 @@ const config = { attributes: false } } - ] + ], + 'react/no-children-prop': ['warn', { allowFunctions: true }] } }; module.exports = config; diff --git a/apps/web/package.json b/apps/web/package.json index 4d12489a..1a14b20b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,9 +18,11 @@ "@radix-ui/themes": "^3.0.2", "@simplewebauthn/browser": "^10.0.0", "@tailwindcss/typography": "^0.5.13", + "@tanstack/react-form": "^0.19.5", "@tanstack/react-query": "^4.36.1", "@tanstack/react-table": "^8.16.0", "@tanstack/react-virtual": "^3.5.0", + "@tanstack/zod-form-adapter": "^0.19.5", "@trpc/client": "10.45.2", "@trpc/react-query": "10.45.2", "@trpc/server": "10.45.2", diff --git a/apps/web/src/app/(login)/_components/password-login.tsx b/apps/web/src/app/(login)/_components/password-login.tsx index 3fe595e5..6c09b38f 100644 --- a/apps/web/src/app/(login)/_components/password-login.tsx +++ b/apps/web/src/app/(login)/_components/password-login.tsx @@ -59,6 +59,7 @@ export default function PasswordLoginButton() { description: 'Redirecting you to create an organization' }); router.push('/join/org'); + return; } toast.success('Sign in successful!', { description: 'Redirecting you to your conversations' diff --git a/apps/web/src/app/(login)/_components/recovery-button.tsx b/apps/web/src/app/(login)/_components/recovery-button.tsx deleted file mode 100644 index b07e29d0..00000000 --- a/apps/web/src/app/(login)/_components/recovery-button.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client'; -import { Button } from '@radix-ui/themes'; -import { toast } from 'sonner'; - -export default function RecoveryButton() { - return ( - - ); -} diff --git a/apps/web/src/app/(login)/page.tsx b/apps/web/src/app/(login)/page.tsx index e70cf932..b5caea98 100644 --- a/apps/web/src/app/(login)/page.tsx +++ b/apps/web/src/app/(login)/page.tsx @@ -2,7 +2,6 @@ import { Flex, Box, Heading, Separator, Badge, Button } from '@radix-ui/themes'; import PasskeyLoginButton from './_components/passkey-login'; import PasswordLoginButton from './_components/password-login'; import Link from 'next/link'; -import RecoveryButton from './_components/recovery-button'; export default async function Page() { return ( @@ -52,7 +51,12 @@ export default async function Page() { - + ); diff --git a/apps/web/src/app/[orgShortCode]/settings/user/security/_components/password-modal.tsx b/apps/web/src/app/[orgShortCode]/settings/user/security/_components/password-modal.tsx deleted file mode 100644 index 691e3e5a..00000000 --- a/apps/web/src/app/[orgShortCode]/settings/user/security/_components/password-modal.tsx +++ /dev/null @@ -1,633 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import { - AlertDialog as Dialog, // Alert Dialogs don't close on outside click - Flex, - Button, - Text, - Spinner, - Tooltip, - Box, - TextField, - Separator, - Card -} from '@radix-ui/themes'; -import { type ModalComponent } from '@/src/hooks/use-awaitable-modal'; -import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; -import { api } from '@/src/lib/trpc'; -import useLoading from '@/src/hooks/use-loading'; -import { Check, Plus, Question } from '@phosphor-icons/react'; -import TogglePasswordBox from '@/src/components/toggle-password'; -import { useDebounce } from '@uidotdev/usehooks'; -import { toDataURL } from 'qrcode'; -import CopyButton from '@/src/components/copy-button'; -import { - InputOTP, - InputOTPGroup, - InputOTPSeparator, - InputOTPSlot -} from '@/src/components/shadcn-ui/input-otp'; -import { downloadAsFile } from '@/src/lib/utils'; - -export function PasswordModal({ - open, - onClose, - onResolve, - verificationToken -}: ModalComponent<{ verificationToken: string }, null>) { - const [password, setPassword] = useState(); - const [error, setError] = useState(null); - const [confirmPassword, setConfirmPassword] = useState( - password ?? '' - ); - const debouncedPassword = useDebounce(password, 1000); - - const checkPasswordStrength = - api.useUtils().auth.signup.checkPasswordStrength; - - const { - data: passwordCheckData, - loading: passwordCheckLoading, - run: checkPassword - } = useLoading( - async (signal) => { - if (!password) return; - if (password.length < 8) { - return { error: 'Password must be at least 8 characters long' }; - } - return await checkPasswordStrength.fetch({ password }, { signal }); - }, - { - onError: (err) => { - setError(err.message); - } - } - ); - - useEffect(() => { - if (typeof debouncedPassword === 'undefined') return; - checkPassword({ clearData: true, clearError: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedPassword]); - - const passwordValid = - passwordCheckData && - 'score' in passwordCheckData && - passwordCheckData.score >= 3 && - password === confirmPassword; - - const setPasswordApi = api.account.security.resetPassword.useMutation(); - const { loading: passwordLoading, run: updatePassword } = useLoading( - async () => { - if (verificationToken === '') - throw new Error('No verification token provided'); - if (!password) throw new Error('Password is required'); - await setPasswordApi.mutateAsync({ - newPassword: password, - verificationToken - }); - onResolve(null); - }, - { - onError: (err) => { - setError(err.message); - } - } - ); - - return ( - - - Set your Password - - - Choose a Password - - - - - - {passwordCheckLoading && ( - - - - Checking password strength - - - )} - {passwordCheckData && 'error' in passwordCheckData && ( - - {passwordCheckData.error} - - )} - {passwordCheckData && 'score' in passwordCheckData && ( - - {passwordCheckData.score >= 3 ? ( - - ) : ( - - )} - = 3 ? 'green' : 'red'}> - Your password is{' '} - { - ['very weak', 'weak', 'fair', 'strong', 'very strong'][ - passwordCheckData.score - ] - } - - - It would take a computer {passwordCheckData.crackTime} to - crack it - - }> - - - - )} - 0 - ? password === confirmPassword - ? 'green' - : 'red' - : undefined - }} - label="Confirm Password" - /> - - - {error} - - - - - - - - ); -} - -/* const PasswordModalStep1 = ({ - password, - setPassword, - onClose, - setStep, - verificationToken -}: { - password?: string; - setPassword: Dispatch>; - onClose: () => void; - setStep: Dispatch>; - verificationToken?: string; -}) => { - const [error, setError] = useState(null); - const [confirmPassword, setConfirmPassword] = useState( - password ?? '' - ); - const debouncedPassword = useDebounce(password, 1000); - - const checkPasswordStrength = - api.useUtils().auth.signup.checkPasswordStrength; - - const { - data: passwordCheckData, - error: passwordCheckError, - loading: passwordCheckLoading, - run: checkPassword - } = useLoading(async (signal) => { - if (!password) return; - if (password.length < 8) { - return { error: 'Password must be at least 8 characters long' }; - } - return await checkPasswordStrength.fetch({ password }, { signal }); - }); - - useEffect(() => { - if (passwordCheckError) { - setError(passwordCheckError.message); - } - }, [passwordCheckError]); - - useEffect(() => { - if (typeof debouncedPassword === 'undefined') return; - checkPassword({ clearData: true, clearError: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedPassword]); - - const passwordValid = - passwordCheckData && - 'score' in passwordCheckData && - passwordCheckData.score >= 3 && - password === confirmPassword; - - return ( - - - {passwordCheckLoading && ( - - - - Checking password strength - - - )} - {passwordCheckData && 'error' in passwordCheckData && ( - - {passwordCheckData.error} - - )} - {passwordCheckData && 'score' in passwordCheckData && ( - - {passwordCheckData.score >= 3 ? ( - - ) : ( - - )} - = 3 ? 'green' : 'red'}> - Your password is{' '} - { - ['very weak', 'weak', 'fair', 'strong', 'very strong'][ - passwordCheckData.score - ] - } - - - It would take a computer {passwordCheckData.crackTime} to crack - it - - }> - - - - )} - 0 - ? password === confirmPassword - ? 'green' - : 'red' - : undefined - }} - label="Confirm Password" - /> - - - {error} - - - - - - ); -}; */ - -/* const PasswordModalStep2 = ({ - username, - password, - twoFactorCode, - setTwoFactorCode, - onResolve, - setStep -}: { - username: string; - password?: string; - twoFactorCode: string; - setTwoFactorCode: Dispatch>; - onResolve: (e: { recoveryCode: string }) => void; - setStep: Dispatch>; -}) => { - const [error, setError] = useState(null); - const [qrCode, setQrCode] = useState(null); - - const twoFaChallenge = - api.useUtils().auth.twoFactorAuthentication.createTwoFactorChallenge; - const signUpWithPassword = - api.auth.password.signUpWithPassword2FA.useMutation(); - - const { - data: twoFaData, - loading: twoFaLoading, - run: generate2Fa - } = useLoading( - async (signal) => { - return await twoFaChallenge - .fetch({ username }, { signal }) - .catch((e: Error) => { - setError(e); - throw e; - }); - }, - { - onSuccess({ uri }) { - void toDataURL(uri, { margin: 3 }).then((qr) => setQrCode(qr)); - } - } - ); - - const totpSecret = twoFaData - ? twoFaData.uri.match(/secret=([^&]+)/)?.[1] ?? '' - : ''; - - const { loading: signUpLoading, run: signUp } = useLoading(async () => { - if (!password || twoFactorCode.length !== 6) return; - const data = await signUpWithPassword.mutateAsync({ - username, - password, - twoFactorCode - }); - - if (data.success) { - onResolve({ - recoveryCode: data.recoveryCode! - }); - } else { - setError(new Error(data.error ?? 'An unknown error occurred')); - } - }); - - useEffect(() => { - generate2Fa(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const inputValid = Boolean(password) && twoFactorCode.length === 6; - - return ( - - {twoFaLoading && ( - - - - Generating 2FA challenge - - - )} - - {twoFaData && !twoFaLoading && ( - - - Scan this QR code with your 2FA app - - - <> - {qrCode && ( - QrCode for 2FA - )} - - - - - - - - - - - - - - Enter the 6-digit code from your 2FA app - - - - - - - - - - - - - - - - - {error && ( - - {error.message} - - )} - - )} - - - - - ); -}; */ - -/* export const recoveryCodeModal = () => - Modal( - ({ onResolve, open, args }) => { - const [downloaded, setDownloaded] = useState(false); - - return ( - - - - Recovery Code - - - - Save this recovery code in a safe place, without this code you - would not be able to recover your account - - - - {args?.recoveryCode} - - - - - - - - ); - } - ); */ diff --git a/apps/web/src/app/recovery/_components/modals.tsx b/apps/web/src/app/recovery/_components/modals.tsx new file mode 100644 index 00000000..bab3dd53 --- /dev/null +++ b/apps/web/src/app/recovery/_components/modals.tsx @@ -0,0 +1,274 @@ +import { type ModalComponent } from '@/src/hooks/use-awaitable-modal'; +import { + AlertDialog as Dialog, + Button, + TextField, + Spinner +} from '@radix-ui/themes'; +import { memo, Suspense, useState } from 'react'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from '@/src/components/shadcn-ui/input-otp'; +import CopyButton from '@/src/components/copy-button'; +import { toDataURL } from 'qrcode'; +import Image from 'next/image'; +import TogglePasswordBox from '@/src/components/toggle-password'; +import { useDebounce } from '@uidotdev/usehooks'; +import { api } from '@/src/lib/trpc'; +import { cn } from '@/src/lib/utils'; +import { type TypeId } from '@u22n/utils'; + +export function PasswordRecoveryModal({ + open, + accountPublicId, + onResolve +}: ModalComponent<{ accountPublicId: TypeId<'account'> }>) { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const debouncedPassword = useDebounce(password, 1000); + + const { data: passwordStrength, isLoading: strengthLoading } = + api.auth.signup.checkPasswordStrength.useQuery( + { + password: debouncedPassword + }, + { + enabled: debouncedPassword.length >= 8 + } + ); + const { + mutateAsync: resetPassword, + isLoading: isResetting, + error + } = api.auth.recovery.resetPassword.useMutation(); + + const passwordValid = + password === confirmPassword && passwordStrength?.allowed; + + return ( + + + + Reset Your Password + + + Enter Your New Password + +
+
+ +
+ {strengthLoading ? ( + <> + + Checking... + + ) : ( + passwordStrength && ( +
+ Your Password is{' '} + { + ['very weak', 'weak', 'fair', 'strong', 'very strong'][ + passwordStrength.score + ] + } + . It would take {passwordStrength.crackTime} to crack. +
+ ) + )} +
+
+ +
+ 0 + ? password === confirmPassword + ? 'green' + : 'red' + : undefined + }} + /> +
+ + {error && ( +
+ {error.message} +
+ )} + + +
+
+
+ ); +} + +export function TwoFactorModal({ + uri, + accountPublicId, + open, + onResolve +}: ModalComponent<{ uri: string; accountPublicId: TypeId<'account'> }>) { + const [otp, setOtp] = useState(''); + const qrCodeSecret = uri.match(/secret=([^&]+)/)?.[1] ?? ''; + const { + mutateAsync: resetTwoFactor, + isLoading: isResetting, + error + } = api.auth.recovery.resetTwoFactor.useMutation(); + + return ( + + + + Setup Your Two Factor Auth + + + Scan the QR Code with your Authenticator App and enter the code + +
+
+ + + + + + + +
+ +
+ + + + + + + + + + + +
+ + {error && ( +
+ {error.message} +
+ )} + + +
+
+
+ ); +} + +const MemoizedQrCode = memo( + function QRCode({ text }: { text?: string }) { + const qrCode = !text + ? Promise.resolve(null) + : toDataURL(text, { margin: 2 }); + + return ( +
+ }> + {qrCode.then((src) => + src ? ( + QrCode for 2FA + ) : null + )} + +
+ ); + }, + (prev, next) => prev.text === next.text +); diff --git a/apps/web/src/app/recovery/page.tsx b/apps/web/src/app/recovery/page.tsx new file mode 100644 index 00000000..0184adee --- /dev/null +++ b/apps/web/src/app/recovery/page.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { Button, Card, Tabs, TextField } from '@radix-ui/themes'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from '@/src/components/shadcn-ui/input-otp'; +import { type FieldApi, useForm } from '@tanstack/react-form'; +import { zodValidator } from '@tanstack/zod-form-adapter'; +import { zodSchemas } from '@u22n/utils'; +import { api } from '@/src/lib/trpc'; +import { z } from 'zod'; +import useAwaitableModal from '@/src/hooks/use-awaitable-modal'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { PasswordRecoveryModal, TwoFactorModal } from './_components/modals'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function FieldInfo({ field }: { field: FieldApi }) { + return ( + <> + {field.state.meta.touchedErrors ? ( + {field.state.meta.touchedErrors} + ) : null} + {field.state.meta.isValidating ? 'Checking...' : null} + + ); +} + +export default function Page() { + const recoveryVerificationTokenApi = + api.useUtils().auth.recovery.getRecoveryVerificationToken; + + const router = useRouter(); + + const [PasswordModalRoot, openPasswordModal] = useAwaitableModal( + PasswordRecoveryModal, + { + accountPublicId: 'a_' + } + ); + + const [TOTPModalRoot, openTOTPModal] = useAwaitableModal(TwoFactorModal, { + uri: '', + accountPublicId: 'a_' + }); + + const form = useForm({ + defaultValues: { + username: '', + password: '', + twoFactorCode: '', + recoveryCode: '', + recoveryType: 'password' + }, + onSubmit: async ({ value }) => { + if (value.recoveryType === 'password') { + const data = await recoveryVerificationTokenApi + .fetch({ + username: value.username, + twoFactorCode: value.twoFactorCode, + recoveryCode: value.recoveryCode + }) + .catch((err: Error) => { + toast.error(err.message); + }); + if (!data) return; + + await openPasswordModal({ accountPublicId: data.accountPublicId }); + toast.success( + 'Your Password has been reset. Login using your new password and setup a new Recovery Code.' + ); + router.push('/'); + } else { + const data = await recoveryVerificationTokenApi + .fetch({ + username: value.username, + password: value.password, + recoveryCode: value.recoveryCode + }) + .catch((err: Error) => { + toast.error(err.message); + }); + if (!data) return; + + await openTOTPModal({ + uri: data.uri, + accountPublicId: data.accountPublicId + }); + toast.success( + 'Your 2FA has been reset. Login using your new 2FA and setup a new Recovery Code.' + ); + router.push('/'); + } + }, + validatorAdapter: zodValidator + }); + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }}> + +

+ Recover Your Credentials +

+
+ + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + + + )} + /> +
+ ( + + + + Recover Password + + + Recover 2FA + + + +
+ + ( + <> + field.handleChange(e)}> + + + + + + + + + + + + )} + /> +
+
+ +
+ + ( + <> + + field.handleChange(e.target.value ?? '') + } + onBlur={field.handleBlur} + /> + + + )} + /> +
+
+
+ )} + /> +
+ + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + + + )} + /> +
+ [ + state.isTouched, + state.canSubmit, + state.isSubmitting + ]} + children={([isTouched, canSubmit, isSubmitting]) => ( + + )} + /> +
+ + + + ); +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 9e2259ec..104f43e9 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -2,7 +2,7 @@ import { isAuthenticated, serverApi } from '@/src/lib/trpc.server'; import { type NextRequest, NextResponse } from 'next/server'; // Known public routes, add more as needed -const publicRoutes = ['/', '/join', '/join/secure']; +const publicRoutes = ['/', '/join', '/join/secure', '/recovery']; const publicDynamicRoutes = ['/join/invite']; export default async function middleware(req: NextRequest) { diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 806a7ebe..75b313ab 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -16,8 +16,23 @@ export const nanoIdToken = customAlphabet( ); export const zodSchemas = { - nanoIdLong: z.string().min(3).max(nanoIdLongLength), - nanoIdToken: () => z.string().min(3).max(nanoIdTokenLength), + nanoIdLong: z + .string() + .min(nanoIdLongLength, { + message: `Token must be ${nanoIdLongLength} characters long` + }) + .max(nanoIdLongLength, { + message: `Token must be ${nanoIdLongLength} characters long` + }), + nanoIdToken: () => + z + .string() + .min(nanoIdTokenLength, { + message: `Token must be ${nanoIdTokenLength} characters long` + }) + .max(nanoIdTokenLength, { + message: `Token must be ${nanoIdTokenLength} characters long` + }), username: (minLength: number = 5) => z .string() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1fe0359..77e69683 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.13 version: 0.5.13(tailwindcss@3.4.3) + '@tanstack/react-form': + specifier: ^0.19.5 + version: 0.19.5(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: ^4.36.1 version: 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -293,6 +296,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.5.0 version: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/zod-form-adapter': + specifier: ^0.19.5 + version: 0.19.5(zod@3.23.8) '@trpc/client': specifier: 10.45.2 version: 10.45.2(@trpc/server@10.45.2) @@ -3654,9 +3660,17 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' + '@tanstack/form-core@0.19.5': + resolution: {integrity: sha512-2ocFAnWTY3sxXemJvKArllnPNMmo9LkviSX88iUXcaClKutvJLfAc+a/lM00zhDKY9G3abXuZCp1OvGmeagszA==} + '@tanstack/query-core@4.36.1': resolution: {integrity: sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==} + '@tanstack/react-form@0.19.5': + resolution: {integrity: sha512-tvuc++2aBt3vEDtHmDlFJYiynPBZ1S6MPGdG8Zs0rNEBNM3VT1khiafXTzPoWpmI6TIfyD/JBuWizFxty7aCIA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + '@tanstack/react-query@4.36.1': resolution: {integrity: sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==} peerDependencies: @@ -3669,6 +3683,12 @@ packages: react-native: optional: true + '@tanstack/react-store@0.3.1': + resolution: {integrity: sha512-PfV271d345It6FdcX4c9gd+llKGddtvau8iJnybTAWmYVyDeFWfIIkiAJ5iNITJmI02AzqgtcV3QLNBBlpBUjA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + '@tanstack/react-table@8.16.0': resolution: {integrity: sha512-rKRjnt8ostqN2fercRVOIH/dq7MAmOENCMvVlKx6P9Iokhh6woBGnIZEkqsY/vEJf1jN3TqLOb34xQGLVRuhAg==} engines: {node: '>=12'} @@ -3682,6 +3702,9 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@tanstack/store@0.3.1': + resolution: {integrity: sha512-A49KN8SpLMWaNmZGPa9K982RQ81W+m7W6iStcQVeKeVS70JZRqkF0fDwKByREPq6qz9/kS0aQFOPQ0W6wIeU5g==} + '@tanstack/table-core@8.16.0': resolution: {integrity: sha512-dCG8vQGk4js5v88/k83tTedWOwjGnIyONrKpHpfmSJB8jwFHl8GSu1sBBxbtACVAPtAQgwNxl0rw1d3RqRM1Tg==} engines: {node: '>=12'} @@ -3697,6 +3720,11 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 + '@tanstack/zod-form-adapter@0.19.5': + resolution: {integrity: sha512-yDucWQgAvDSqy5HuYnHgnW1SWN5MBTl8p+qPw0VK/da8r04LTINGhbwjiZEo3GF4LO9vo9i/ISsO+3mD+Psr5g==} + peerDependencies: + zod: ^3.x + '@tiptap/core@2.3.1': resolution: {integrity: sha512-ycpQlmczAOc05TgB5sc3RUTEEBXAVmS8MR9PqQzg96qidaRfVkgE+2w4k7t83PMHl2duC0MGqOCy96pLYwSpeg==} peerDependencies: @@ -4951,6 +4979,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decode-formdata@0.4.0: + resolution: {integrity: sha512-/OMUlsRLrSgHPOWCwembsFFTT4DY7Ts9GGlwK8v9yeLOyYZSPKIfn/1oOuV9UmpQ9CZi5JeyT8edunRoBOOl5g==} + deep-equal@1.0.1: resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} @@ -7715,6 +7746,17 @@ packages: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} + rehackt@0.0.3: + resolution: {integrity: sha512-aBRHudKhOWwsTvCbSoinzq+Lej/7R8e8UoPvLZo5HirZIIBLGAgdG7SL9QpdcBoQ7+3QYPi3lRLknAzXBlhZ7g==} + peerDependencies: + '@types/react': '*' + react: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + replace-in-file@6.3.5: resolution: {integrity: sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==} engines: {node: '>=10'} @@ -12264,8 +12306,23 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.3 + '@tanstack/form-core@0.19.5': + dependencies: + '@tanstack/store': 0.3.1 + '@tanstack/query-core@4.36.1': {} + '@tanstack/react-form@0.19.5(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/form-core': 0.19.5 + '@tanstack/react-store': 0.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + decode-formdata: 0.4.0 + react: 18.3.1 + rehackt: 0.0.3(@types/react@18.3.1)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - react-dom + '@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/query-core': 4.36.1 @@ -12274,6 +12331,13 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-store@0.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/store': 0.3.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.2.0(react@18.3.1) + '@tanstack/react-table@8.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/table-core': 8.16.0 @@ -12286,6 +12350,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@tanstack/store@0.3.1': {} + '@tanstack/table-core@8.16.0': {} '@tanstack/virtual-core@3.2.0': {} @@ -12297,6 +12363,11 @@ snapshots: '@tanstack/virtual-core': 3.2.0 vue: 3.4.24(typescript@5.4.5) + '@tanstack/zod-form-adapter@0.19.5(zod@3.23.8)': + dependencies: + '@tanstack/form-core': 0.19.5 + zod: 3.23.8 + '@tiptap/core@2.3.1(@tiptap/pm@2.3.0)': dependencies: '@tiptap/pm': 2.3.0 @@ -13755,6 +13826,8 @@ snapshots: decamelize@1.2.0: {} + decode-formdata@0.4.0: {} + deep-equal@1.0.1: {} deep-is@0.1.4: {} @@ -16982,6 +17055,11 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 + rehackt@0.0.3(@types/react@18.3.1)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.1 + react: 18.3.1 + replace-in-file@6.3.5: dependencies: chalk: 4.1.2