🎨 refactor: Auth Components UI Consistency (#7651)

* 🔧 refactor: Improve Error Handling and UI Consistency in Auth Components

* 🔧 refactor: Email Templates

* 🔧 refactor: Enhance LoginForm with loading state and spinner

* 🔧 refactor: Replace button elements with Button component and enhance UI consistency across Auth forms
This commit is contained in:
Marco Beretta 2025-06-02 13:49:10 +02:00 committed by GitHub
parent 80bc49db8d
commit 37c94beeac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 284 additions and 140 deletions

View file

@ -22,17 +22,71 @@
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } .ie-container table, .mso-container table { table-layout: fixed; } * {
line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; }
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
</style>
</head>

View file

@ -22,18 +22,78 @@
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
p {
margin: 0;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
#u_body a {
color: #0000ee;
text-decoration: underline;
}
</style>
</head>

View file

@ -22,18 +22,75 @@
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } .ie-container table, .mso-container table { table-layout: fixed; } * {
line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
#u_body a {
color: #0000ee;
text-decoration: underline;
}
</style>
</head>

View file

@ -1,23 +1,12 @@
import { TranslationKeys, useLocalize } from '~/hooks';
import { BlinkAnimation } from './BlinkAnimation';
import { TStartupConfig } from 'librechat-data-provider';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialLoginRender from './SocialLoginRender';
import { ThemeSelector } from '~/components/ui';
import { BlinkAnimation } from './BlinkAnimation';
import { ThemeSelector } from '~/components';
import { Banner } from '../Banners';
import Footer from './Footer';
const ErrorRender = ({ children }: { children: React.ReactNode }) => (
<div className="mt-16 flex justify-center">
<div
role="alert"
aria-live="assertive"
className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200"
>
{children}
</div>
</div>
);
function AuthLayout({
children,
header,
@ -40,19 +29,29 @@ function AuthLayout({
const hasStartupConfigError = startupConfigError !== null && startupConfigError !== undefined;
const DisplayError = () => {
if (hasStartupConfigError) {
return <ErrorRender>{localize('com_auth_error_login_server')}</ErrorRender>;
return (
<div className="mx-auto sm:max-w-sm">
<ErrorMessage>{localize('com_auth_error_login_server')}</ErrorMessage>
</div>
);
} else if (error === 'com_auth_error_invalid_reset_token') {
return (
<ErrorRender>
{localize('com_auth_error_invalid_reset_token')}{' '}
<a className="font-semibold text-green-600 hover:underline" href="/forgot-password">
{localize('com_auth_click_here')}
</a>{' '}
{localize('com_auth_to_try_again')}
</ErrorRender>
<div className="mx-auto sm:max-w-sm">
<ErrorMessage>
{localize('com_auth_error_invalid_reset_token')}{' '}
<a className="font-semibold text-green-600 hover:underline" href="/forgot-password">
{localize('com_auth_click_here')}
</a>{' '}
{localize('com_auth_to_try_again')}
</ErrorMessage>
</div>
);
} else if (error != null && error) {
return <ErrorRender>{localize(error)}</ErrorRender>;
return (
<div className="mx-auto sm:max-w-sm">
<ErrorMessage>{localize(error)}</ErrorMessage>
</div>
);
}
return null;
};
@ -87,8 +86,8 @@ function AuthLayout({
{children}
{!pathname.includes('2fa') &&
(pathname.includes('login') || pathname.includes('register')) && (
<SocialLoginRender startupConfig={startupConfig} />
)}
<SocialLoginRender startupConfig={startupConfig} />
)}
</div>
</div>
<Footer startupConfig={startupConfig} />

View file

@ -2,7 +2,7 @@ export const ErrorMessage = ({ children }: { children: React.ReactNode }) => (
<div
role="alert"
aria-live="assertive"
className="relative mt-6 rounded-lg border border-red-500/20 bg-red-50/50 px-6 py-4 text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100"
className="relative mt-6 rounded-xl border border-red-500/20 bg-red-50/50 px-6 py-4 text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100"
>
{children}
</div>

View file

@ -3,11 +3,11 @@ import { useEffect, useState } from 'react';
import { useAuthContext } from '~/hooks/AuthContext';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialButton from '~/components/Auth/SocialButton';
import { OpenIDIcon } from '~/components';
import { getLoginError } from '~/utils';
import { useLocalize } from '~/hooks';
import LoginForm from './LoginForm';
import SocialButton from '~/components/Auth/SocialButton';
import { OpenIDIcon } from '~/components';
function Login() {
const localize = useLocalize();

View file

@ -5,6 +5,7 @@ import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
import { ThemeContext, useLocalize } from '~/hooks';
import { Spinner, Button } from '~/components';
type TLoginFormProps = {
onSubmit: (data: TLoginUser) => void;
@ -20,7 +21,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
register,
getValues,
handleSubmit,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
@ -165,15 +166,16 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
)}
<div className="mt-6">
<button
<Button
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
disabled={requireCaptcha && !turnstileToken}
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
disabled={(requireCaptcha && !turnstileToken) || isSubmitting}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{localize('com_auth_continue')}
</button>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
</>

View file

@ -4,10 +4,10 @@ import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from './ErrorMessage';
import { Spinner } from '~/components/svg';
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';
import type { TLoginLayoutContext } from '~/common';
import { Spinner, Button } from '~/components';
import { ErrorMessage } from './ErrorMessage';
const Registration: React.FC = () => {
const navigate = useNavigate();
@ -194,7 +194,7 @@ const Registration: React.FC = () => {
)}
<div className="mt-6">
<button
<Button
disabled={
Object.keys(errors).length > 0 ||
isSubmitting ||
@ -202,10 +202,11 @@ const Registration: React.FC = () => {
}
type="submit"
aria-label="Submit registration"
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
variant="submit"
className="h-12 w-full rounded-2xl"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button>
</Button>
</div>
</form>

View file

@ -5,12 +5,13 @@ import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-q
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { FC } from 'react';
import type { TLoginLayoutContext } from '~/common';
import { Spinner, Button } from '~/components';
import { useLocalize } from '~/hooks';
const BodyTextWrapper: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div
className="relative mt-6 rounded-lg border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
className="relative mt-6 rounded-xl border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
{children}
@ -44,6 +45,7 @@ function RequestPasswordReset() {
const { startupConfig, setHeaderText } = useOutletContext<TLoginLayoutContext>();
const requestPasswordReset = useRequestPasswordResetMutation();
const { isLoading } = requestPasswordReset;
const onSubmit = (data: TRequestPasswordReset) => {
requestPasswordReset.mutate(data, {
@ -105,23 +107,12 @@ function RequestPasswordReset() {
},
})}
aria-invalid={!!errors.email}
className="
peer w-full rounded-lg border border-gray-300 bg-transparent px-4 py-3
text-base text-gray-900 placeholder-transparent transition-all
focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20
dark:border-gray-700 dark:text-white dark:focus:border-green-500
"
placeholder="email@example.com"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="email"
className="
absolute -top-2 left-2 z-10 bg-white px-2 text-sm text-gray-600
transition-all peer-placeholder-shown:top-3 peer-placeholder-shown:text-base
peer-placeholder-shown:text-gray-500 peer-focus:-top-2 peer-focus:text-sm
peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400
dark:peer-focus:text-green-500
"
className="absolute -top-2 left-2 z-10 bg-white px-2 text-sm text-gray-600 transition-all peer-placeholder-shown:top-3 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-focus:-top-2 peer-focus:text-sm peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500"
>
{localize('com_auth_email_address')}
</label>
@ -133,18 +124,15 @@ function RequestPasswordReset() {
)}
</div>
<div className="space-y-4">
<button
<Button
aria-label="Continue with password reset"
type="submit"
disabled={!!errors.email}
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
disabled={!!errors.email || isLoading}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{localize('com_auth_continue')}
</button>
{isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button>
<a
href="/login"
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"

View file

@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useResetPasswordMutation } from 'librechat-data-provider/react-query';
import type { TResetPassword } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { Spinner, Button } from '~/components';
import { useLocalize } from '~/hooks';
function ResetPassword() {
@ -12,7 +13,7 @@ function ResetPassword() {
register,
handleSubmit,
watch,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<TResetPassword>();
const navigate = useNavigate();
const [params] = useSearchParams();
@ -35,18 +36,20 @@ function ResetPassword() {
return (
<>
<div
className="relative mb-8 mt-4 rounded-2xl border border-green-400 bg-green-100 px-4 py-3 text-center text-green-700 dark:bg-gray-900 dark:text-white"
className="relative mt-6 rounded-xl border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
{localize('com_auth_login_with_new_password')}
<div className="flex flex-col space-y-4">
<p>{localize('com_auth_login_with_new_password')}</p>
<Button
onClick={() => navigate('/login')}
aria-label={localize('com_auth_sign_in')}
variant="submit"
>
{localize('com_auth_continue')}
</Button>
</div>
</div>
<button
onClick={() => navigate('/login')}
aria-label={localize('com_auth_sign_in')}
className="w-full transform rounded-2xl bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
{localize('com_auth_continue')}
</button>
</>
);
}
@ -89,20 +92,12 @@ function ResetPassword() {
},
})}
aria-invalid={!!errors.password}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="password"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
@ -124,20 +119,12 @@ function ResetPassword() {
validate: (value) => value === password || localize('com_auth_password_not_match'),
})}
aria-invalid={!!errors.confirm_password}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="confirm_password"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password_confirm')}
</label>
@ -159,19 +146,15 @@ function ResetPassword() {
)}
</div>
<div className="mt-6">
<button
disabled={!!errors.password || !!errors.confirm_password}
<Button
type="submit"
aria-label={localize('com_auth_submit_registration')}
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
disabled={!!errors.password || !!errors.confirm_password || isSubmitting}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{localize('com_auth_continue')}
</button>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
);