Skip to content

Commit

Permalink
feat: allow enable and disable 2fa
Browse files Browse the repository at this point in the history
  • Loading branch information
hyoban committed Jan 10, 2025
1 parent f747882 commit 6415e02
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 37 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/[email protected]",
"react-router": "7.0.2",
"react-selecto": "^1.26.3",
Expand Down
159 changes: 159 additions & 0 deletions apps/renderer/src/modules/profile/two-factor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Button } from "@follow/components/ui/button/index.js"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@follow/components/ui/form/index.jsx"
import { Input } from "@follow/components/ui/input/index.js"
import { Label } from "@follow/components/ui/label/index.js"
import { twoFactor } from "@follow/shared/auth"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import QRCode from "react-qr-code"
import { toast } from "sonner"
import { z } from "zod"

import { useWhoami } from "~/atoms/user"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useHasPassword } from "~/queries/auth"

import { NoPasswordHint } from "./update-password-form"

const passwordSchema = z.string().min(8).max(128)
const totpCodeSchema = z.string().length(6).regex(/^\d+$/)

const passwordFormSchema = z.object({
password: passwordSchema,
})
type PasswordFormValues = z.infer<typeof passwordFormSchema>

const totpFormSchema = z.object({
code: totpCodeSchema,
})
type TOTPFormValues = z.infer<typeof totpFormSchema>

function PasswordForm<
T extends "password" | "totp",
Value extends T extends "password" ? PasswordFormValues : TOTPFormValues,
>({ onSubmitMutationFn, type }: { onSubmitMutationFn: (values: Value) => Promise<void>; type: T }) {
const { t } = useTranslation("settings")

const form = useForm<Value>({
resolver: zodResolver(type === "password" ? passwordFormSchema : totpFormSchema),
defaultValues: (type === "password" ? { password: "" } : { code: "" }) as any,
})

const updateMutation = useMutation({
mutationFn: onSubmitMutationFn,
onError: (error) => {
toast.error(error.message)
},
})

function onSubmit(values: Value) {
updateMutation.mutate(values)
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-[35ch] max-w-full space-y-4">
<FormField
control={form.control}
name={(type === "password" ? "password" : "code") as any}
render={({ field }) => (
<FormItem>
<FormLabel>{t("profile.current_password.label")}</FormLabel>
<FormControl>
<Input
autoFocus
type={type === "password" ? "password" : "text"}
placeholder={t("profile.current_password.label")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-right">
<Button type="submit" isLoading={updateMutation.isPending}>
{t("profile.submit")}
</Button>
</div>
</form>
</Form>
)
}

const TwoFactorForm = () => {
const user = useWhoami()
const [totpURI, setTotpURI] = useState("")
return totpURI ? (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-center">
<QRCode value={totpURI} />
</div>
<PasswordForm
type="totp"
onSubmitMutationFn={async (values) => {
const res = await twoFactor.verifyTotp({ code: values.code })
if (res.error) {
throw new Error(res.error.message)
}
toast.success("Two-factor authentication enabled")
}}
/>
</div>
) : (
<PasswordForm
type="password"
onSubmitMutationFn={async (values) => {
const res = user?.twoFactorEnabled
? await twoFactor.disable({ password: values.password })
: await twoFactor.enable({ password: values.password })
if (res.error) {
throw new Error(res.error.message)
}
if ("totpURI" in res.data) setTotpURI(res.data?.totpURI ?? "")
}}
/>
)
}

export function TwoFactor() {
const { t } = useTranslation("settings")
const { present } = useModalStack()
const user = useWhoami()
const actionTitle = user?.twoFactorEnabled
? t("profile.two_factor.disable")
: t("profile.two_factor.enable")

const { data: hasPassword } = useHasPassword()

return (
<div className="flex items-center justify-between">
<Label>{t("profile.two_factor.label")}</Label>
{hasPassword ? (
<Button
variant="outline"
onClick={() => {
present({
title: actionTitle,
content: TwoFactorForm,
})
}}
>
{actionTitle}
</Button>
) : (
<NoPasswordHint i18nKey="profile.two_factor.no_password" />
)}
</div>
)
}
58 changes: 40 additions & 18 deletions apps/renderer/src/modules/profile/update-password-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import {
import { Input } from "@follow/components/ui/input/index.js"
import { Label } from "@follow/components/ui/label/index.js"
import { changePassword } from "@follow/shared/auth"
import { env } from "@follow/shared/env"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"

Expand Down Expand Up @@ -129,28 +130,49 @@ const UpdateExistingPasswordForm = () => {
}

export const UpdatePasswordForm = () => {
const { data: hasPassword, isLoading } = useHasPassword()
const { data: hasPassword } = useHasPassword()

const { t } = useTranslation("settings")
const { present } = useModalStack()
if (isLoading || !hasPassword) {
return null
}

return (
<div className="mt-4 flex items-center justify-between">
<Label className="mb-4">{t("profile.password.label")}</Label>
<Button
variant="outline"
onClick={() =>
present({
title: t("profile.change_password.label"),
content: UpdateExistingPasswordForm,
})
}
>
{t("profile.change_password.label")}
</Button>
<div className="flex items-center justify-between">
<Label>{t("profile.password.label")}</Label>
{hasPassword ? (
<Button
variant="outline"
onClick={() =>
present({
title: t("profile.change_password.label"),
content: UpdateExistingPasswordForm,
})
}
>
{t("profile.change_password.label")}
</Button>
) : (
<NoPasswordHint i18nKey="profile.no_password" />
)}
</div>
)
}

export const NoPasswordHint = ({ i18nKey }: { i18nKey: string }) => {
return (
<p className="text-sm text-muted-foreground">
<Trans
ns="settings"
i18nKey={i18nKey as any}
components={{
Link: (
<a
href={`${env.VITE_WEB_URL}/forget-password`}
className="text-accent"
target="_blank"
/>
),
}}
/>
</p>
)
}
8 changes: 6 additions & 2 deletions apps/renderer/src/pages/settings/(settings)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Divider } from "@follow/components/ui/divider/Divider.js"
import { AccountManagement } from "~/modules/profile/account-management"
import { EmailManagement } from "~/modules/profile/email-management"
import { ProfileSettingForm } from "~/modules/profile/profile-setting-form"
import { TwoFactor } from "~/modules/profile/two-factor"
import { UpdatePasswordForm } from "~/modules/profile/update-password-form"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
Expand All @@ -24,8 +25,11 @@ export function Component() {

<Divider className="mx-auto my-8 w-3/4" />

<AccountManagement />
<UpdatePasswordForm />
<div className="space-y-4">
<AccountManagement />
<UpdatePasswordForm />
<TwoFactor />
</div>
</>
)
}
5 changes: 5 additions & 0 deletions locales/settings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,16 @@
"profile.name.description": "Your public display name.",
"profile.name.label": "Display Name",
"profile.new_password.label": "New Password",
"profile.no_password": "<Link>Reset</Link> your password to set a new one.",
"profile.password.label": "Password",
"profile.reset_password_mail_sent": "Reset password mail sent.",
"profile.sidebar_title": "Profile",
"profile.submit": "Submit",
"profile.title": "Profile Settings",
"profile.two_factor.disable": "Disable 2FA",
"profile.two_factor.enable": "Enable 2FA",
"profile.two_factor.label": "Two Factor",
"profile.two_factor.no_password": "You need to <Link>set</Link> a password before enabling 2FA.",
"profile.updateSuccess": "Profile updated.",
"profile.update_password_success": "Password updated.",
"rsshub.addModal.access_key_label": "Access Key (Optional)",
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { env } from "@follow/shared/env"
import type { authPlugins } from "@follow/shared/hono"
import type { BetterAuthClientPlugin } from "better-auth/client"
import { inferAdditionalFields } from "better-auth/client/plugins"
import { inferAdditionalFields, twoFactorClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"

import { IN_ELECTRON, WEB_URL } from "./constants"
Expand Down Expand Up @@ -32,7 +32,7 @@ const serverPlugins = [

const authClient = createAuthClient({
baseURL: `${env.VITE_API_URL}/better-auth`,
plugins: serverPlugins,
plugins: [...serverPlugins, twoFactorClient()],
})

// @keep-sorted
Expand All @@ -51,6 +51,7 @@ export const {
signIn,
signOut,
signUp,
twoFactor,
unlinkAccount,
updateUser,
} = authClient
Expand Down
17 changes: 2 additions & 15 deletions packages/shared/src/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11520,6 +11520,7 @@ declare const auth: {
} & {
image: string | null;
handle: string | null;
twoFactorEnabled: boolean | null;
};
session: {
id: string;
Expand Down Expand Up @@ -13472,6 +13473,7 @@ declare const auth: {
} & {
image: string | null;
handle: string | null;
twoFactorEnabled: boolean | null;
};
session: {
id: string;
Expand Down Expand Up @@ -16529,21 +16531,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
status: 200;
};
};
} & {
"/accounts": {
$get: {
input: {};
output: {
code: 0;
data: {
duplicateEmails: string[];
duplicateAccountIds: string[];
};
};
outputFormat: "json";
status: 200;
};
};
}, "/probes"> | hono_types.MergeSchemaPath<{
"/": {
$post: {
Expand Down
Loading

0 comments on commit 6415e02

Please sign in to comment.