mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user