318 lines
9.0 KiB
TypeScript
318 lines
9.0 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
IonButton,
|
|
IonButtons,
|
|
IonContent,
|
|
IonHeader,
|
|
IonIcon,
|
|
IonInput,
|
|
IonPage,
|
|
IonTitle,
|
|
IonToolbar,
|
|
} from '@ionic/react';
|
|
import { chevronBackOutline, lockClosedOutline } from 'ionicons/icons';
|
|
import { useHistory, useLocation } from 'react-router-dom';
|
|
import { supabase } from '../supabase';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import OtpInputSlots from '../components/OtpInputSlots';
|
|
import '../styles/auth.css';
|
|
|
|
interface LocationState {
|
|
email?: string;
|
|
}
|
|
|
|
const VerifyResetPage: React.FC = () => {
|
|
const history = useHistory();
|
|
const location = useLocation<LocationState>();
|
|
const { user, refreshProfile, profileStatus } = useAuth();
|
|
const email = location.state?.email;
|
|
|
|
const [code, setCode] = useState<string[]>(['', '', '', '', '', '']);
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
const [cooldown, setCooldown] = useState(0);
|
|
const [passwordUpdated, setPasswordUpdated] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!email) {
|
|
history.replace('/forgot-password');
|
|
}
|
|
}, [email, history]);
|
|
|
|
useEffect(() => {
|
|
if (cooldown <= 0) return;
|
|
const timer = setTimeout(() => setCooldown((current) => current - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [cooldown]);
|
|
|
|
useEffect(() => {
|
|
if (user && passwordUpdated && profileStatus !== 'loading') {
|
|
loadProfileAndNavigate();
|
|
}
|
|
}, [user, passwordUpdated, profileStatus]);
|
|
|
|
const showMessage = (message: string, kind: 'error' | 'status') => {
|
|
if (kind === 'error') {
|
|
setError(message);
|
|
setStatus(null);
|
|
setTimeout(() => setError(null), 4000);
|
|
} else {
|
|
setStatus(message);
|
|
setError(null);
|
|
setTimeout(() => setStatus(null), 4000);
|
|
}
|
|
};
|
|
|
|
const loadProfileAndNavigate = async () => {
|
|
if (!user) return;
|
|
|
|
const data = await refreshProfile();
|
|
|
|
if (
|
|
!data ||
|
|
!data.first_name ||
|
|
!data.last_name ||
|
|
!data.phone ||
|
|
!data.country_of_residence
|
|
) {
|
|
history.replace('/setup-profile');
|
|
} else {
|
|
history.replace('/home');
|
|
}
|
|
};
|
|
|
|
const handleDigitChange = (index: number, value: string) => {
|
|
const nextCode = [...code];
|
|
nextCode[index] = value.replace(/\D/g, '').slice(-1);
|
|
setCode(nextCode);
|
|
};
|
|
|
|
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
|
|
event.preventDefault();
|
|
const digits = event.clipboardData
|
|
.getData('Text')
|
|
.replace(/\D/g, '')
|
|
.slice(0, 6);
|
|
if (!digits) return;
|
|
|
|
const nextCode = ['', '', '', '', '', ''];
|
|
digits.split('').forEach((digit, index) => {
|
|
nextCode[index] = digit;
|
|
});
|
|
setCode(nextCode);
|
|
};
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
|
|
const token = code.join('');
|
|
if (token.length !== 6) {
|
|
showMessage('Please enter the 6-digit reset code', 'error');
|
|
return;
|
|
}
|
|
|
|
if (newPassword.length < 6) {
|
|
showMessage('Password must be at least 6 characters', 'error');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
setStatus(null);
|
|
|
|
const { error: verifyError } = await supabase.auth.verifyOtp({
|
|
email: email!,
|
|
token,
|
|
type: 'recovery',
|
|
});
|
|
|
|
if (verifyError) {
|
|
showMessage(verifyError.message, 'error');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const { error: updateError } = await supabase.auth.updateUser({
|
|
password: newPassword,
|
|
});
|
|
|
|
if (updateError) {
|
|
showMessage(updateError.message, 'error');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
showMessage('Password updated', 'status');
|
|
setPasswordUpdated(true);
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleResend = async () => {
|
|
if (cooldown > 0 || !email) return;
|
|
|
|
const { error: resendError } =
|
|
await supabase.auth.resetPasswordForEmail(email);
|
|
if (resendError) {
|
|
showMessage(resendError.message, 'error');
|
|
return;
|
|
}
|
|
|
|
showMessage('A new reset code has been sent', 'status');
|
|
setCooldown(60);
|
|
};
|
|
|
|
return (
|
|
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
|
<IonHeader className="ion-no-border">
|
|
<IonToolbar
|
|
style={
|
|
{
|
|
'--background': 'transparent',
|
|
'--border-width': '0px',
|
|
'--color': '#111827',
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<IonButtons slot="start">
|
|
<IonButton
|
|
fill="clear"
|
|
onClick={() => history.goBack()}
|
|
style={
|
|
{
|
|
'--color': '#111827',
|
|
'--border-radius': '12px',
|
|
} as React.CSSProperties
|
|
}
|
|
aria-label="Go back"
|
|
>
|
|
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
</IonButton>
|
|
</IonButtons>
|
|
<IonTitle style={{ fontSize: '18px', fontWeight: 700 }}>
|
|
New password
|
|
</IonTitle>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent
|
|
className="auth-content"
|
|
style={
|
|
{
|
|
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
|
'--padding-start': '0px',
|
|
'--padding-end': '0px',
|
|
'--padding-top': '0px',
|
|
'--padding-bottom': '0px',
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<div className="auth-shell">
|
|
<div
|
|
className="auth-intro-block"
|
|
style={{ alignItems: 'flex-start', textAlign: 'left' }}
|
|
>
|
|
<div className="auth-icon-container">
|
|
<IonIcon icon={lockClosedOutline} />
|
|
</div>
|
|
<div>
|
|
<h1 className="auth-intro-heading">Enter your reset code</h1>
|
|
<p className="auth-intro-body" style={{ marginTop: '8px' }}>
|
|
Use the 6-digit code sent to <strong>{email}</strong>, then
|
|
choose a new password.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="auth-form-card" style={{ marginBottom: '12px' }}>
|
|
<OtpInputSlots
|
|
value={code}
|
|
onChange={handleDigitChange}
|
|
onPaste={handlePaste}
|
|
disabled={loading}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleResend}
|
|
disabled={cooldown > 0 || loading}
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
border: 'none',
|
|
padding: '8px',
|
|
fontSize: '14px',
|
|
fontWeight: 700,
|
|
color: cooldown > 0 ? '#9ca3af' : '#6d28d9',
|
|
}}
|
|
>
|
|
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="auth-form-card">
|
|
<IonInput
|
|
type="password"
|
|
label="New password"
|
|
labelPlacement="floating"
|
|
value={newPassword}
|
|
onIonInput={(event) => setNewPassword(event.detail.value ?? '')}
|
|
disabled={loading}
|
|
placeholder="Enter a new password"
|
|
style={
|
|
{
|
|
'--background': '#fafafa',
|
|
'--border-radius': '12px',
|
|
'--padding-start': '14px',
|
|
'--padding-end': '14px',
|
|
'--highlight-color-focused': '#6d28d9',
|
|
} as React.CSSProperties
|
|
}
|
|
/>
|
|
|
|
{error && (
|
|
<p className="auth-status-text" style={{ color: '#dc2626' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
{status && (
|
|
<p className="auth-status-text" style={{ color: '#16a34a' }}>
|
|
{status}
|
|
</p>
|
|
)}
|
|
|
|
<IonButton
|
|
type="submit"
|
|
expand="block"
|
|
disabled={
|
|
loading ||
|
|
code.join('').length !== 6 ||
|
|
newPassword.length < 6
|
|
}
|
|
style={
|
|
{
|
|
'--background': '#6d28d9',
|
|
'--background-activated': '#5b21b6',
|
|
'--border-radius': '999px',
|
|
'--box-shadow': 'none',
|
|
'--color': '#ffffff',
|
|
height: '52px',
|
|
fontSize: '15px',
|
|
fontWeight: 700,
|
|
marginTop: '4px',
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
{loading ? 'Updating password...' : 'Update password'}
|
|
</IonButton>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
};
|
|
|
|
export default VerifyResetPage;
|