Skip to content

Commit

Permalink
feat: 2fa (#2540)
Browse files Browse the repository at this point in the history
Co-authored-by: Innei <tukon479@gmail.com>
Co-authored-by: Innei <i@innei.in>
  • Loading branch information
3 people authored Jan 16, 2025
1 parent 4ead508 commit fb531b1
Show file tree
Hide file tree
Showing 27 changed files with 674 additions and 139 deletions.
1 change: 1 addition & 0 deletions apps/renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"react-i18next": "^15.1.3",
"react-intersection-observer": "9.13.1",
"react-ios-pwa-prompt": "^2.0.6",
"react-qr-code": "^2.0.15",
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
"react-router": "7.0.2",
"react-selecto": "^1.26.3",
Expand Down
32 changes: 26 additions & 6 deletions apps/renderer/src/lib/error-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,34 @@ import { Markdown } from "~/components/ui/markdown/Markdown"
import { isDev } from "~/constants"
import { DebugRegistry } from "~/modules/debug/registry"

export const getFetchErrorMessage = (error: Error) => {
export const getFetchErrorInfo = (
error: Error,
): {
message: string
code?: number
} => {
if (error instanceof FetchError) {
try {
const json = JSON.parse(error.response?._data)

const { reason, code, message } = json
const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
return `${i18nMessage}${reason ? `: ${reason}` : ""}`
return {
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`,
code,
}
} catch {
return error.message
return { message: error.message }
}
}

return error.message
return { message: error.message }
}

export const getFetchErrorMessage = (error: Error) => {
const { message } = getFetchErrorInfo(error)
return message
}

/**
Expand All @@ -39,6 +52,7 @@ export const toastFetchError = (
) => {
let message = ""
let _reason = ""
let code: number | undefined

if (error instanceof FetchError) {
try {
Expand All @@ -47,11 +61,12 @@ export const toastFetchError = (
? JSON.parse(error.response?._data)
: error.response?._data

const { reason, code, message: _message } = json
const { reason, code: _code, message: _message } = json
code = _code
message = _message

const tValue = t(`errors:${code}` as any)
const i18nMessage = tValue === code.toString() ? message : tValue
const i18nMessage = tValue === code?.toString() ? message : tValue

message = i18nMessage

Expand All @@ -63,6 +78,11 @@ export const toastFetchError = (
}
}

// 2fa errors are handled by the form
if (code === 4007 || code === 4008) {
return
}

const toastOptions: ExternalToast = {
..._toastOptions,
classNames: {
Expand Down
38 changes: 33 additions & 5 deletions apps/renderer/src/modules/auth/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "@follow/components/ui/form/index.js"
import { Input } from "@follow/components/ui/input/Input.js"
import type { LoginRuntime } from "@follow/shared/auth"
import { loginHandler, signUp } from "@follow/shared/auth"
import { loginHandler, signUp, twoFactor } from "@follow/shared/auth"
import { env } from "@follow/shared/env"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
Expand All @@ -19,13 +19,16 @@ import { z } from "zod"

import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"

import { TOTPForm } from "../profile/two-factor"

const formSchema = z.object({
email: z.string().email(),
password: z.string().max(128),
})

export function LoginWithPassword({ runtime }: { runtime?: LoginRuntime }) {
const { t } = useTranslation("app")
const { t: tSettings } = useTranslation("settings")
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
Expand All @@ -39,12 +42,37 @@ export function LoginWithPassword({ runtime }: { runtime?: LoginRuntime }) {
const { dismiss } = useCurrentModal()

async function onSubmit(values: z.infer<typeof formSchema>) {
const res = await loginHandler("credential", runtime ?? "browser", values)
const res = await loginHandler("credential", runtime ?? "browser", {
email: values.email,
password: values.password,
})
if (res?.error) {
toast.error(res.error.message)
return
}
window.location.reload()

if ((res?.data as any)?.twoFactorRedirect) {
present({
title: tSettings("profile.totp_code.title"),
content: () => {
return (
<TOTPForm
onSubmitMutationFn={async (values) => {
const { data, error } = await twoFactor.verifyTotp({ code: values.code })
if (!data || error) {
throw new Error(error?.message ?? "Invalid TOTP code")
}
}}
onSuccess={() => {
window.location.reload()
}}
/>
)
},
})
} else {
window.location.reload()
}
}

return (
Expand Down Expand Up @@ -86,9 +114,9 @@ export function LoginWithPassword({ runtime }: { runtime?: LoginRuntime }) {
</a>
<Button
type="submit"
className="w-full"
buttonClassName="text-base !mt-3"
buttonClassName="text-base !mt-3 w-full"
disabled={!isValid}
isLoading={form.formState.isSubmitting}
>
{t("login.continueWith", { provider: t("words.email") })}
</Button>
Expand Down
6 changes: 4 additions & 2 deletions apps/renderer/src/modules/boost/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useFeedById } from "~/store/feed"
import { feedIconSelector } from "~/store/feed/selector"

import { FeedIcon } from "../feed/feed-icon"
import { useTOTPModalWrapper } from "../profile/hooks"
import { BoostProgress } from "./boost-progress"
import { BoostingContributors } from "./boosting-contributors"
import { LevelBenefits } from "./level-benefits"
Expand All @@ -33,11 +34,12 @@ export const BoostModalContent = ({ feedId }: { feedId: string }) => {
const { data: boostStatus, isLoading } = useBoostStatusQuery(feedId)
const boostFeedMutation = useBoostFeedMutation()
const { dismiss } = useCurrentModal()
const present = useTOTPModalWrapper(boostFeedMutation.mutateAsync)

const handleBoost = useCallback(() => {
if (boostFeedMutation.isPending) return
boostFeedMutation.mutate({ feedId, amount: amountBigInt.toString() })
}, [amountBigInt, boostFeedMutation, feedId])
present({ feedId, amount: amountBigInt.toString() })
}, [amountBigInt, boostFeedMutation.isPending, feedId, present])

const feed = useFeedById(feedId, feedIconSelector)

Expand Down
8 changes: 7 additions & 1 deletion apps/renderer/src/modules/discover/list-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useListById } from "~/store/list"
import { useSubscriptionByFeedId } from "~/store/subscription"
import { feedUnreadActions } from "~/store/unread"

import { useTOTPModalWrapper } from "../profile/hooks"
import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup"

const formSchema = z.object({
Expand Down Expand Up @@ -251,8 +252,13 @@ const ListInnerForm = ({
},
})

const preset = useTOTPModalWrapper(followMutation.mutateAsync)
function onSubmit(values: z.infer<typeof formSchema>) {
followMutation.mutate(values)
if (isSubscribed) {
followMutation.mutate(values)
} else {
preset(values)
}
}

const t = useI18n()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { useTOTPModalWrapper } from "~/modules/profile/hooks"
import { Balance } from "~/modules/wallet/balance"
import { useWallet, wallet as walletActions } from "~/queries/wallet"

Expand Down Expand Up @@ -88,9 +89,10 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
})
},
})
const present = useTOTPModalWrapper(mutation.mutateAsync, { force: true })

const onSubmit = (values: z.infer<typeof formSchema>) => {
mutation.mutate(values)
present(values)
}

useEffect(() => {
Expand Down
58 changes: 58 additions & 0 deletions apps/renderer/src/modules/profile/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { capitalizeFirstLetter } from "@follow/utils/utils"
import { createElement, lazy, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { parse } from "tldts"

import { useWhoami } from "~/atoms/user"
import { useAsyncModal } from "~/components/ui/modal/helper/use-async-modal"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { getFetchErrorInfo } from "~/lib/error-parser"
import { users } from "~/queries/users"

import { TOTPForm, TwoFactorForm } from "./two-factor"

const LazyUserProfileModalContent = lazy(() =>
import("./user-profile-modal").then((mod) => ({ default: mod.UserProfileModalContent })),
)
Expand Down Expand Up @@ -98,3 +104,55 @@ export const usePresentUserProfileModal = (variant: Variant = "dialog") => {
[present, presentAsync, variant],
)
}

export function useTOTPModalWrapper<T>(
callback: (input: T) => Promise<any>,
options?: { force?: boolean },
) {
const { present } = useModalStack()
const { t } = useTranslation("settings")
const user = useWhoami()
return useCallback(
async (input: T) => {
const presentTOTPModal = () => {
if (!user?.twoFactorEnabled) {
toast.error(t("profile.two_factor.enable_notice"))
present({
title: t("profile.two_factor.enable"),
content: TwoFactorForm,
})
return
}

present({
title: t("profile.totp_code.title"),
content: ({ dismiss }) => {
return createElement(TOTPForm, {
async onSubmitMutationFn(values) {
await callback({
...input,
TOTPCode: values.code,
})
dismiss()
},
})
},
})
}

if (options?.force) {
presentTOTPModal()
}

try {
await callback(input)
} catch (error) {
const { code } = getFetchErrorInfo(error as Error)
if (code === 4008) {
presentTOTPModal()
}
}
},
[callback, options?.force, present, t, user?.twoFactorEnabled],
)
}
Loading

0 comments on commit fb531b1

Please sign in to comment.