Public Access
/reset-password page for the email request and the new password, ChangePasswordCard in settings, password reset button on the admin users page, 'Forgot password?' link on the login page, de/en texts.
138 lines
5.5 KiB
React
138 lines
5.5 KiB
React
import {useState} from 'react';
|
|
import {Link, useLocation} from 'react-router-dom';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {useForm} from 'react-hook-form';
|
|
import {zodResolver} from '@hookform/resolvers/zod';
|
|
import {Loader2} from 'lucide-react';
|
|
|
|
import {Button} from '@/components/ui/button';
|
|
import {Input} from '@/components/ui/input';
|
|
import AuthLayout from '../components/AuthLayout.jsx';
|
|
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
|
import FormField from '../components/FormField.jsx';
|
|
import NewPasswordFields from '../components/NewPasswordFields.jsx';
|
|
import {confirmEmailSchema} from '../utils/validation.js';
|
|
import {clientFetch} from '../utils/api.js';
|
|
|
|
const post = (path, body) => clientFetch(`${import.meta.env.VITE_API_URL}${path}`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const failureMessage = (t, response) => {
|
|
if (response.status === 429) {
|
|
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
|
return t('RESET_PASSWORD.ERROR_TOO_MANY', {minutes});
|
|
}
|
|
return t('RESET_PASSWORD.ERROR_GENERIC');
|
|
};
|
|
|
|
/**
|
|
* Both ends of "forgot password". Without a fragment: ask for the mail. With the fragment of the mail link: choose
|
|
* the new password. The token is in the fragment, so no server log sees it, and it is sent only on the click: a mail
|
|
* scanner that opens the link must not use it up.
|
|
*/
|
|
export default function ResetPasswordPage() {
|
|
const token = useLocation().hash.slice(1);
|
|
// A new link in the same tab only changes the fragment: key restarts the state.
|
|
return <ResetPassword key={token} token={token}/>;
|
|
}
|
|
|
|
function ResetPassword({token}) {
|
|
const {t} = useTranslation();
|
|
const [state, setState] = useState(token ? 'idle' : 'request');
|
|
|
|
return (
|
|
<AuthLayout title={t('RESET_PASSWORD.TITLE')}>
|
|
{state === 'idle' && (
|
|
<NewPasswordForm token={token} onDone={() => setState('done')} onInvalid={() => setState('invalid')}/>
|
|
)}
|
|
{state === 'done' && <>
|
|
<FeedbackAlert message={t('RESET_PASSWORD.DONE')} isError={false}/>
|
|
<Link to="/login" className="block text-center text-sm font-medium text-primary hover:underline">
|
|
{t('RESET_PASSWORD.TO_LOGIN')}
|
|
</Link>
|
|
</>}
|
|
{state === 'invalid' && <FeedbackAlert message={t('RESET_PASSWORD.INVALID')} isError/>}
|
|
{(state === 'request' || state === 'invalid') && <RequestForm/>}
|
|
</AuthLayout>
|
|
);
|
|
}
|
|
|
|
function RequestForm() {
|
|
const {t} = useTranslation();
|
|
const [email, setEmail] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [feedback, setFeedback] = useState({message: '', isError: false});
|
|
|
|
const send = async (event) => {
|
|
event.preventDefault();
|
|
setIsLoading(true);
|
|
setFeedback({message: '', isError: false});
|
|
try {
|
|
const response = await post('/api/auth/password-reset', {email});
|
|
setFeedback(response.ok
|
|
? {message: t('RESET_PASSWORD.SENT'), isError: false}
|
|
: {message: failureMessage(t, response), isError: true});
|
|
} catch (error) {
|
|
console.error('Password reset API error:', error);
|
|
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={send} className="space-y-3" noValidate>
|
|
<FormField id="reset-email" label={t('RESET_PASSWORD.EMAIL')} t={t}>
|
|
<Input id="reset-email" type="email" autoComplete="email" value={email}
|
|
onChange={(e) => setEmail(e.target.value)}/>
|
|
</FormField>
|
|
<Button type="submit" disabled={isLoading} className="w-full">
|
|
{isLoading && <Loader2 className="animate-spin"/>}
|
|
{t('RESET_PASSWORD.SEND')}
|
|
</Button>
|
|
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function NewPasswordForm({token, onDone, onInvalid}) {
|
|
const {t} = useTranslation();
|
|
const [error, setError] = useState('');
|
|
const {register, handleSubmit, formState: {errors, isSubmitting}} = useForm({
|
|
resolver: zodResolver(confirmEmailSchema),
|
|
defaultValues: {password: '', passwordConfirmation: ''},
|
|
});
|
|
|
|
const submit = async ({password}) => {
|
|
setError('');
|
|
try {
|
|
const response = await post('/api/auth/password-reset/confirm', {token, password});
|
|
if (response.ok) {
|
|
onDone();
|
|
} else if (response.status === 410 || response.status === 400) {
|
|
onInvalid();
|
|
} else {
|
|
setError(failureMessage(t, response));
|
|
}
|
|
} catch (err) {
|
|
console.error('Password reset confirm error:', err);
|
|
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
|
|
<p className="text-sm text-muted-foreground">{t('RESET_PASSWORD.INTRO')}</p>
|
|
<NewPasswordFields register={register} errors={errors} t={t}/>
|
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
|
{isSubmitting && <Loader2 className="animate-spin"/>}
|
|
{t('RESET_PASSWORD.SUBMIT')}
|
|
</Button>
|
|
<FeedbackAlert message={error} isError/>
|
|
</form>
|
|
);
|
|
}
|