From a6ef340c093dcabac9cfdb341397b479dc1b9995 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 18:13:12 +0200 Subject: [PATCH] feat(web): add a password input and label the two-factor code fields One PasswordInput replaces seven copies of the show/hide password field. The OTP code fields get a label, prompts and keyword and name inputs ignore Enter during IME composition, and a failed profile save shows an error instead of hanging. --- apps/web/src/components/input/chip-input.tsx | 2 + .../src/components/input/password-input.tsx | 38 ++++ apps/web/src/dialogs/auth/change-password.tsx | 98 +++------- .../src/dialogs/auth/disable-two-factor.tsx | 50 ++---- .../src/dialogs/auth/enable-two-factor.tsx | 52 ++---- apps/web/src/features/auth/pages/consent.tsx | 29 +-- apps/web/src/features/auth/pages/login.tsx | 167 +++++++++--------- apps/web/src/features/auth/pages/register.tsx | 51 ++---- .../features/auth/pages/reset-password.tsx | 53 ++---- .../features/auth/pages/resume-password.tsx | 52 ++---- .../src/features/auth/pages/verify-2fa.tsx | 5 +- .../resume/builder/password-dialog.tsx | 5 +- .../src/features/settings/account/profile.tsx | 9 +- apps/web/src/hooks/use-confirm.tsx | 3 + 14 files changed, 254 insertions(+), 360 deletions(-) create mode 100644 apps/web/src/components/input/password-input.tsx diff --git a/apps/web/src/components/input/chip-input.tsx b/apps/web/src/components/input/chip-input.tsx index 410741e80..6c6b5dc87 100644 --- a/apps/web/src/components/input/chip-input.tsx +++ b/apps/web/src/components/input/chip-input.tsx @@ -22,6 +22,7 @@ import { Input } from "@reactive-resume/ui/components/input"; import { Kbd } from "@reactive-resume/ui/components/kbd"; import { cn } from "@reactive-resume/utils/style"; import { useControlledState } from "@/hooks/use-controlled-state"; +import { isImeComposing } from "@/libs/keyboard"; import { DRAG_SETTLE } from "@/libs/motion"; const RETURN_KEY = "Enter"; @@ -316,6 +317,7 @@ export function ChipInput({ const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { + if (isImeComposing(e)) return; if (e.key === "Enter" || e.key === ",") { e.preventDefault(); diff --git a/apps/web/src/components/input/password-input.tsx b/apps/web/src/components/input/password-input.tsx new file mode 100644 index 000000000..3b2f8a3ed --- /dev/null +++ b/apps/web/src/components/input/password-input.tsx @@ -0,0 +1,38 @@ +import { t } from "@lingui/core/macro"; +import { useToggle } from "usehooks-ts"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { Input } from "@reactive-resume/ui/components/input"; + +type PasswordInputProps = Omit, "type">; + +export function PasswordInput(props: PasswordInputProps) { + const [visible, toggleVisible] = useToggle(false); + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/dialogs/auth/change-password.tsx b/apps/web/src/dialogs/auth/change-password.tsx index 5521bc385..f7f41f252 100644 --- a/apps/web/src/dialogs/auth/change-password.tsx +++ b/apps/web/src/dialogs/auth/change-password.tsx @@ -2,7 +2,6 @@ import type { DialogProps } from "../store"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useQueryClient } from "@tanstack/react-query"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { @@ -14,8 +13,8 @@ import { } from "@reactive-resume/ui/components/dialog"; import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { Icon } from "@reactive-resume/ui/components/icon"; -import { Input } from "@reactive-resume/ui/components/input"; import { toast } from "@reactive-resume/ui/components/toast"; +import { PasswordInput } from "@/components/input/password-input"; import { useFormBlocker } from "@/hooks/use-form-blocker"; import { authClient } from "@/libs/auth/client"; import { getReadableErrorMessage } from "@/libs/error-message"; @@ -36,9 +35,6 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) { const queryClient = useQueryClient(); const closeDialog = useDialogStore((state) => state.closeDialog); - const [showCurrentPassword, toggleShowCurrentPassword] = useToggle(false); - const [showNewPassword, toggleShowNewPassword] = useToggle(false); - const form = useAppForm({ defaultValues: { currentPassword: "", @@ -104,41 +100,19 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) { Current Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} @@ -150,37 +124,19 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) { New Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} diff --git a/apps/web/src/dialogs/auth/disable-two-factor.tsx b/apps/web/src/dialogs/auth/disable-two-factor.tsx index f8425f39e..b78bed5bb 100644 --- a/apps/web/src/dialogs/auth/disable-two-factor.tsx +++ b/apps/web/src/dialogs/auth/disable-two-factor.tsx @@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { @@ -15,8 +14,8 @@ import { } from "@reactive-resume/ui/components/dialog"; import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { Icon } from "@reactive-resume/ui/components/icon"; -import { Input } from "@reactive-resume/ui/components/input"; import { toast } from "@reactive-resume/ui/components/toast"; +import { PasswordInput } from "@/components/input/password-input"; import { useFormBlocker } from "@/hooks/use-form-blocker"; import { authClient } from "@/libs/auth/client"; import { getReadableErrorMessage } from "@/libs/error-message"; @@ -31,7 +30,6 @@ const formSchema = z.object({ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">) { const router = useRouter(); const queryClient = useQueryClient(); - const [showPassword, toggleShowPassword] = useToggle(false); const closeDialog = useDialogStore((state) => state.closeDialog); const form = useAppForm({ @@ -102,39 +100,19 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable"> Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} diff --git a/apps/web/src/dialogs/auth/enable-two-factor.tsx b/apps/web/src/dialogs/auth/enable-two-factor.tsx index 973c16133..524fef2e4 100644 --- a/apps/web/src/dialogs/auth/enable-two-factor.tsx +++ b/apps/web/src/dialogs/auth/enable-two-factor.tsx @@ -7,7 +7,6 @@ import { useRouter } from "@tanstack/react-router"; import { QRCodeSVG } from "qrcode.react"; import { useState } from "react"; import { match } from "ts-pattern"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { @@ -23,6 +22,7 @@ import { Input } from "@reactive-resume/ui/components/input"; import { OTPField } from "@reactive-resume/ui/components/otp-field"; import { toast } from "@reactive-resume/ui/components/toast"; import { downloadWithAnchor } from "@reactive-resume/utils/file"; +import { PasswordInput } from "@/components/input/password-input"; import { useFormBlocker } from "@/hooks/use-form-blocker"; import { authClient } from "@/libs/auth/client"; import { getReadableErrorMessage } from "@/libs/error-message"; @@ -56,7 +56,6 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) const [backupCodes, setBackupCodes] = useState(null); const [step, setStep] = useState("enable"); - const [showPassword, toggleShowPassword] = useToggle(false); const closeDialog = useDialogStore((state) => state.closeDialog); const enableForm = useAppForm({ @@ -198,39 +197,19 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} @@ -281,6 +260,9 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) {(field) => ( 0}> + + Verification code + (); @@ -39,17 +54,9 @@ export function OAuthConsentPage({ oauthQuery, email }: OAuthConsentPageProps) { if (pending || !client || !validRequest) return; setPending(true); setError(undefined); - try { - // This is the only point that grants access: an explicit button press. - // Better Auth validates the signed request, session, and request origin. - const { data, error } = await authClient.oauth2.consent({ accept, oauth_query: oauthQuery }); - if (error || !isOAuthRedirect(data)) { - setError(t`Could not complete this connection. Restart the connection from your client and try again.`); - setPending(false); - } - // Better Auth's redirect plugin follows a successful provider response. - } catch { - setError(t`Could not complete this connection. Restart the connection from your client and try again.`); + const failure = await requestConsent(accept, oauthQuery); + if (failure) { + setError(failure); setPending(false); } } diff --git a/apps/web/src/features/auth/pages/login.tsx b/apps/web/src/features/auth/pages/login.tsx index 8ca48e4a5..0a410d56a 100644 --- a/apps/web/src/features/auth/pages/login.tsx +++ b/apps/web/src/features/auth/pages/login.tsx @@ -1,15 +1,16 @@ +import type { QueryClient } from "@tanstack/react-query"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { Icon } from "@reactive-resume/ui/components/icon"; import { Input } from "@reactive-resume/ui/components/input"; import { toast } from "@reactive-resume/ui/components/toast"; +import { PasswordInput } from "@/components/input/password-input"; import { authClient } from "@/libs/auth/client"; import { orpc } from "@/libs/orpc/client"; import { sessionQueryKey } from "@/libs/root-context"; @@ -27,6 +28,69 @@ type Props = { disableSignups: boolean; }; +type SubmitLoginOptions = { + value: z.infer; + callbackURL: string | undefined; + reauthenticate: boolean | undefined; + navigate: ReturnType; + queryClient: QueryClient; + router: ReturnType; +}; + +async function submitLogin({ value, callbackURL, reauthenticate, navigate, queryClient, router }: SubmitLoginOptions) { + const toastId = toast.add({ type: "loading", description: t`Signing in...` }); + + try { + const isEmail = value.identifier.includes("@"); + + const result = isEmail + ? await authClient.signIn.email({ + email: value.identifier, + password: value.password, + ...getOAuthSignInOptions(callbackURL), + }) + : await authClient.signIn.username({ + username: value.identifier, + password: value.password, + ...getOAuthSignInOptions(callbackURL), + }); + + if (result.error) { + toast.add({ + type: "error", + description: + result.error.message || + t({ + comment: "Fallback toast when sign-in fails and no server error message is available", + message: "Failed to sign in. Please try again.", + }), + id: toastId, + }); + return; + } + + const requiresTwoFactor = + result.data && + typeof result.data === "object" && + "twoFactorRedirect" in result.data && + result.data.twoFactorRedirect; + + if (requiresTwoFactor) { + toast.close(toastId); + void navigate({ to: "/auth/verify-2fa", search: { callbackURL, reauthenticate }, replace: true }); + return; + } + + toast.close(toastId); + if (isOAuthRedirect(result.data)) return; + await queryClient.invalidateQueries({ queryKey: sessionQueryKey }); + await router.invalidate(); + void navigate(getAuthRedirectOptions(callbackURL)); + } catch { + toast.add({ type: "error", description: t`Failed to sign in. Please try again.`, id: toastId }); + } +} + export function LoginPage({ disableEmailAuth, disableSignups }: Props) { const router = useRouter(); const { callbackURL, reauthenticate } = useSearch({ from: "/auth" }); @@ -34,7 +98,6 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) { const queryClient = useQueryClient(); const hasStartedConditionalPasskeyRef = useRef(false); - const [showPassword, toggleShowPassword] = useToggle(false); const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions()); @@ -42,57 +105,7 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) { defaultValues: { identifier: "", password: "" }, validators: { onSubmit: formSchema }, onSubmit: async ({ value }) => { - const toastId = toast.add({ type: "loading", description: t`Signing in...` }); - - try { - const isEmail = value.identifier.includes("@"); - - const result = isEmail - ? await authClient.signIn.email({ - email: value.identifier, - password: value.password, - ...getOAuthSignInOptions(callbackURL), - }) - : await authClient.signIn.username({ - username: value.identifier, - password: value.password, - ...getOAuthSignInOptions(callbackURL), - }); - - if (result.error) { - toast.add({ - type: "error", - description: - result.error.message || - t({ - comment: "Fallback toast when sign-in fails and no server error message is available", - message: "Failed to sign in. Please try again.", - }), - id: toastId, - }); - return; - } - - const requiresTwoFactor = - result.data && - typeof result.data === "object" && - "twoFactorRedirect" in result.data && - result.data.twoFactorRedirect; - - if (requiresTwoFactor) { - toast.close(toastId); - void navigate({ to: "/auth/verify-2fa", search: { callbackURL, reauthenticate }, replace: true }); - return; - } - - toast.close(toastId); - if (isOAuthRedirect(result.data)) return; - await queryClient.invalidateQueries({ queryKey: sessionQueryKey }); - await router.invalidate(); - void navigate(getAuthRedirectOptions(callbackURL)); - } catch { - toast.add({ type: "error", description: t`Failed to sign in. Please try again.`, id: toastId }); - } + await submitLogin({ value, callbackURL, reauthenticate, navigate, queryClient, router }); }, }); @@ -204,41 +217,19 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) { } /> -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + />
)} diff --git a/apps/web/src/features/auth/pages/register.tsx b/apps/web/src/features/auth/pages/register.tsx index 209ec6be2..e2d43b02e 100644 --- a/apps/web/src/features/auth/pages/register.tsx +++ b/apps/web/src/features/auth/pages/register.tsx @@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro"; import { useQueryClient } from "@tanstack/react-query"; import { Link, useSearch } from "@tanstack/react-router"; import { useState } from "react"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert"; import { Button } from "@reactive-resume/ui/components/button"; @@ -12,6 +11,7 @@ import { Icon } from "@reactive-resume/ui/components/icon"; import { Input } from "@reactive-resume/ui/components/input"; import { toast } from "@reactive-resume/ui/components/toast"; import { cn } from "@reactive-resume/utils/style"; +import { PasswordInput } from "@/components/input/password-input"; import { authClient } from "@/libs/auth/client"; import { ENTER_CLASS } from "@/libs/motion"; import { sessionQueryKey } from "@/libs/root-context"; @@ -42,7 +42,6 @@ export function RegisterPage({ disableEmailAuth }: Props) { const { callbackURL, reauthenticate } = useSearch({ from: "/auth" }); const queryClient = useQueryClient(); const [submitted, setSubmitted] = useState(false); - const [showPassword, toggleShowPassword] = useToggle(false); const form = useAppForm({ defaultValues: { name: "", username: "", email: "", password: "" }, @@ -215,41 +214,19 @@ export function RegisterPage({ disableEmailAuth }: Props) { Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} diff --git a/apps/web/src/features/auth/pages/reset-password.tsx b/apps/web/src/features/auth/pages/reset-password.tsx index 0a5a3ac8d..0e9762d31 100644 --- a/apps/web/src/features/auth/pages/reset-password.tsx +++ b/apps/web/src/features/auth/pages/reset-password.tsx @@ -1,13 +1,11 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useNavigate } from "@tanstack/react-router"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Icon } from "@reactive-resume/ui/components/icon"; -import { Input } from "@reactive-resume/ui/components/input"; import { toast } from "@reactive-resume/ui/components/toast"; +import { PasswordInput } from "@/components/input/password-input"; import { authClient } from "@/libs/auth/client"; import { useAppForm } from "@/libs/tanstack-form"; @@ -21,7 +19,6 @@ type Props = { export function ResetPasswordPage({ token }: Props) { const navigate = useNavigate(); - const [showPassword, toggleShowPassword] = useToggle(false); const form = useAppForm({ defaultValues: { password: "" }, @@ -81,41 +78,19 @@ export function ResetPasswordPage({ token }: Props) { New Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} diff --git a/apps/web/src/features/auth/pages/resume-password.tsx b/apps/web/src/features/auth/pages/resume-password.tsx index d3b6e0f1f..dcb63a8de 100644 --- a/apps/web/src/features/auth/pages/resume-password.tsx +++ b/apps/web/src/features/auth/pages/resume-password.tsx @@ -3,13 +3,12 @@ import { Trans } from "@lingui/react/macro"; import { ORPCError } from "@orpc/client"; import { useMutation } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useToggle } from "usehooks-ts"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { Icon } from "@reactive-resume/ui/components/icon"; -import { Input } from "@reactive-resume/ui/components/input"; import { toast } from "@reactive-resume/ui/components/toast"; +import { PasswordInput } from "@/components/input/password-input"; import { getReadableErrorMessage } from "@/libs/error-message"; import { orpc } from "@/libs/orpc/client"; import { useAppForm } from "@/libs/tanstack-form"; @@ -26,7 +25,6 @@ type ResumePasswordPageProps = { export function ResumePasswordPage({ username, slug, redirectPath }: ResumePasswordPageProps) { const navigate = useNavigate(); - const [showPassword, toggleShowPassword] = useToggle(false); const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions()); @@ -100,41 +98,19 @@ export function ResumePasswordPage({ username, slug, redirectPath }: ResumePassw Password -
- field.handleChange(event.target.value)} - /> - } - /> - - -
+ field.handleChange(event.target.value)} + /> + } + /> )} diff --git a/apps/web/src/features/auth/pages/verify-2fa.tsx b/apps/web/src/features/auth/pages/verify-2fa.tsx index 73bbbf46d..e04bf6f94 100644 --- a/apps/web/src/features/auth/pages/verify-2fa.tsx +++ b/apps/web/src/features/auth/pages/verify-2fa.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; -import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form"; +import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { Icon } from "@reactive-resume/ui/components/icon"; import { Input } from "@reactive-resume/ui/components/input"; import { OTPField } from "@reactive-resume/ui/components/otp-field"; @@ -101,6 +101,9 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification className="justify-self-center" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0} > + + {backupCode ? Backup code : Verification code} + ) => setDraft(event.target.value), onBlur: () => void commit(), onKeyDown: (event: React.KeyboardEvent) => { + if (isImeComposing(event)) return; if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setDraft(value); diff --git a/apps/web/src/hooks/use-confirm.tsx b/apps/web/src/hooks/use-confirm.tsx index 6244dfbaf..a49cdddd4 100644 --- a/apps/web/src/hooks/use-confirm.tsx +++ b/apps/web/src/hooks/use-confirm.tsx @@ -12,6 +12,7 @@ import { } from "@reactive-resume/ui/components/alert-dialog"; import { Input } from "@reactive-resume/ui/components/input"; import { cn } from "@reactive-resume/utils/style"; +import { isImeComposing } from "@/libs/keyboard"; type AskOptions = { description?: string; @@ -66,8 +67,10 @@ export function ConfirmDialogProvider({ children }: { children: React.ReactNode {state.withInput && ( setValue(event.target.value)} onKeyDown={(event) => { + if (isImeComposing(event)) return; if (event.key === "Enter") answer(value); }} />