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.
This commit is contained in:
Amruth Pillai
2026-09-29 18:13:12 +02:00
parent 39f30ac9a1
commit a6ef340c09
14 changed files with 254 additions and 360 deletions
@@ -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<HTMLInputElement>) => {
if (isImeComposing(e)) return;
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
@@ -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<React.ComponentProps<"input">, "type">;
export function PasswordInput(props: PasswordInputProps) {
const [visible, toggleVisible] = useToggle(false);
return (
<div className="flex items-center gap-x-1.5">
<Input {...props} type={visible ? "text" : "password"} />
<Button
size="icon"
variant="ghost"
type="button"
onClick={toggleVisible}
aria-pressed={visible}
aria-label={
visible
? t({
comment: "Accessible label for button that hides a visible password",
message: "Hide password",
})
: t({
comment: "Accessible label for button that reveals a masked password",
message: "Show password",
})
}
>
<Icon name={visible ? "visibility" : "visibility_off"} size={16} />
</Button>
</div>
);
}
+27 -71
View File
@@ -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">) {
<FormLabel>
<Trans>Current Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showCurrentPassword ? "text" : "password"}
autoComplete="current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
<span className="sr-only">
{showCurrentPassword
? t({
comment: "Accessible label for toggle button that hides the visible current password",
message: "Hide password",
})
: t({
comment: "Accessible label for toggle button that reveals the masked current password",
message: "Show password",
})}
</span>
{showCurrentPassword ? (
<Icon name="visibility" size={16} />
) : (
<Icon name="visibility_off" size={16} />
)}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
@@ -150,37 +124,19 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
<FormLabel>
<Trans>New Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showNewPassword ? "text" : "password"}
autoComplete="new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowNewPassword}>
<span className="sr-only">
{showNewPassword
? t({
comment: "Accessible label for toggle button that hides the visible new password",
message: "Hide password",
})
: t({
comment: "Accessible label for toggle button that reveals the masked new password",
message: "Show password",
})}
</span>
{showNewPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
@@ -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">
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
<span className="sr-only">
{showPassword
? t({
comment:
"Accessible label for toggle button that hides the visible password in two-factor disable dialog",
message: "Hide password",
})
: t({
comment:
"Accessible label for toggle button that reveals the masked password in two-factor disable dialog",
message: "Show password",
})}
</span>
{showPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
+17 -35
View File
@@ -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<string[] | null>(null);
const [step, setStep] = useState<TwoFactorSetupStep>("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">)
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
<span className="sr-only">
{showPassword
? t({
comment:
"Accessible label for toggle button that hides the visible password in two-factor setup",
message: "Hide password",
})
: t({
comment:
"Accessible label for toggle button that reveals the masked password in two-factor setup",
message: "Show password",
})}
</span>
{showPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
@@ -281,6 +260,9 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
<verifyForm.Field name="code">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel className="sr-only">
<Trans>Verification code</Trans>
</FormLabel>
<FormControl
render={
<OTPField
+18 -11
View File
@@ -13,6 +13,21 @@ type OAuthConsentPageProps = {
email: string;
};
/** Resolves with an error message, or null when Better Auth's redirect plugin takes over. */
async function requestConsent(accept: boolean, oauthQuery: string) {
const failure = t`Could not complete this connection. Restart the connection from your client and try again.`;
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)) return failure;
// Better Auth's redirect plugin follows a successful provider response.
return null;
} catch {
return failure;
}
}
export function OAuthConsentPage({ oauthQuery, email }: OAuthConsentPageProps) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
@@ -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);
}
}
+79 -88
View File
@@ -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<typeof formSchema>;
callbackURL: string | undefined;
reauthenticate: boolean | undefined;
navigate: ReturnType<typeof useNavigate>;
queryClient: QueryClient;
router: ReturnType<typeof useRouter>;
};
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) {
}
/>
</div>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="section-login current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={toggleShowPassword}
aria-label={
showPassword
? t({
comment: "Accessible label for button that hides the password in login form",
message: "Hide password",
})
: t({
comment: "Accessible label for button that reveals the password in login form",
message: "Show password",
})
}
>
{showPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="section-login current-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
+14 -37
View File
@@ -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) {
<FormLabel>
<Trans comment="Label for password input on registration form">Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="section-register new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={toggleShowPassword}
aria-label={
showPassword
? t({
comment: "Accessible label for button that hides password in registration form",
message: "Hide password",
})
: t({
comment: "Accessible label for button that reveals password in registration form",
message: "Show password",
})
}
>
{showPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="section-register new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
@@ -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) {
<FormLabel>
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={toggleShowPassword}
aria-label={
showPassword
? t({
comment: "Accessible label for button that hides password in reset-password form",
message: "Hide password",
})
: t({
comment: "Accessible label for button that reveals password in reset-password form",
message: "Show password",
})
}
>
{showPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
@@ -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
<FormLabel>
<Trans comment="Label for password input on protected resume access form">Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={toggleShowPassword}
aria-label={
showPassword
? t({
comment: "Accessible label for button that hides password on protected resume screen",
message: "Hide password",
})
: t({
comment: "Accessible label for button that reveals password on protected resume screen",
message: "Show password",
})
}
>
{showPassword ? <Icon name="visibility" size={16} /> : <Icon name="visibility_off" size={16} />}
</Button>
</div>
<FormControl
render={
<PasswordInput
min={6}
max={64}
autoComplete="new-password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
@@ -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}
>
<FormLabel className="sr-only">
{backupCode ? <Trans>Backup code</Trans> : <Trans>Verification code</Trans>}
</FormLabel>
<FormControl
render={
backupCode ? (
@@ -48,10 +48,9 @@ export function ResumePasswordDialog({ onSubmit, onClose }: ResumePasswordDialog
onClose();
} catch (error) {
setError(error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`);
} finally {
submitting.current = false;
setIsPending(false);
}
submitting.current = false;
setIsPending(false);
};
return (
@@ -19,6 +19,7 @@ import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { isImeComposing } from "@/libs/keyboard";
import { orpc } from "@/libs/orpc/client";
import { sessionQueryKey } from "@/libs/root-context";
import { SettingsSection } from "../section";
@@ -68,7 +69,12 @@ function SavedField({
const invalid = validate(next);
if (invalid) return setError(invalid);
setSaving(true);
const failure = await save(next);
let failure: string | null;
try {
failure = await save(next);
} catch {
failure = t`Couldn't save. Try again.`;
}
setSaving(false);
setError(failure);
};
@@ -84,6 +90,7 @@ function SavedField({
onChange: (event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value),
onBlur: () => void commit(),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
if (isImeComposing(event)) return;
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(value);
+3
View File
@@ -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 && (
<Input
value={value}
aria-label={state.title}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (isImeComposing(event)) return;
if (event.key === "Enter") answer(value);
}}
/>