Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 2fa #2540

Merged
merged 26 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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/[email protected]",
"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
Loading