feat: implement premium Email 2FA authentication integrated with auth flow
This commit is contained in:
@@ -21,6 +21,7 @@ interface SettingsProps extends PageProps {
|
||||
enabled: boolean;
|
||||
qr_code: string | null;
|
||||
secret: string | null;
|
||||
email_enabled: boolean;
|
||||
recovery_codes: string[];
|
||||
};
|
||||
}
|
||||
@@ -186,6 +187,25 @@ export default function SettingsIndex({ twoFactor }: SettingsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEmail2FA = async (enable: boolean) => {
|
||||
const { value: password } = await Swal.fire({
|
||||
title: enable ? 'Enable Email 2FA' : 'Disable Email 2FA',
|
||||
text: 'Enter your password to confirm.',
|
||||
input: 'password',
|
||||
inputPlaceholder: 'Your current password',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Confirm',
|
||||
confirmButtonColor: '#3D4E4B',
|
||||
});
|
||||
if (password) {
|
||||
router.post(route('two-factor.email.toggle'), { password, enabled: enable }, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => swal.success('Success', `Email Two-Factor Authentication has been ${enable ? 'enabled' : 'disabled'} successfully.`),
|
||||
onError: (errs) => swal.error('Error', errs.password || 'Incorrect password.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Account Settings" />
|
||||
@@ -426,6 +446,34 @@ export default function SettingsIndex({ twoFactor }: SettingsProps) {
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email 2FA Setup */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<svg className="w-6 h-6 text-[#D4A017]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-bold text-[#3D4E4B]">Two-Factor Authentication via Email</div>
|
||||
<div className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
{twoFactor.email_enabled
|
||||
? 'Email 2FA is active. Every login attempt will require an OTP code sent to your email.'
|
||||
: 'Receive a 6-digit OTP code in your email to secure your login sessions.'}
|
||||
</div>
|
||||
</div>
|
||||
{twoFactor.email_enabled ? (
|
||||
<button onClick={() => handleToggleEmail2FA(false)} type="button"
|
||||
className="h-9 px-5 text-xs font-bold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition-all">
|
||||
Disable Email 2FA
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => handleToggleEmail2FA(true)} type="button"
|
||||
className="h-9 px-5 text-xs font-bold text-[#3D4E4B] border border-gray-200 rounded-xl hover:bg-gray-50 transition-all">
|
||||
Enable Email 2FA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,38 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Head, useForm, router } from '@inertiajs/react';
|
||||
import { swal } from '@/lib/swal';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
interface Props {
|
||||
type: 'email' | 'totp';
|
||||
}
|
||||
|
||||
export default function TwoFactorChallenge({ type = 'totp' }: Props) {
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
const { data, setData, post, processing, errors } = useForm({ code: '' });
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('two-factor.verify'), { preserveScroll: true });
|
||||
};
|
||||
|
||||
const handleResend = () => {
|
||||
if (resending) return;
|
||||
setResending(true);
|
||||
router.post(route('two-factor.resend'), {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setResending(false);
|
||||
swal.success('Sent!', 'A new verification code has been sent to your email.');
|
||||
},
|
||||
onError: (err) => {
|
||||
setResending(false);
|
||||
swal.error('Error', err.code || 'Failed to resend verification code.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isEmail = type === 'email';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E3EBE8] flex items-center justify-center p-4">
|
||||
<Head title="Two-Factor Authentication" />
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
{/* Logo / Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-[#3D4E4B] mb-4">
|
||||
<svg className="w-7 h-7 text-[#D4A017]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-[#3D4E4B] mb-4 shadow-lg shadow-[#3D4E4B]/10">
|
||||
{isEmail && !useRecovery ? (
|
||||
<svg className="w-7 h-7 text-[#D4A017]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-7 h-7 text-[#D4A017]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-[#3D4E4B] tracking-tight">Two-Factor Authentication</h1>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">
|
||||
{useRecovery ? 'Enter a recovery code to continue' : 'Enter the 6-digit code from your authenticator app'}
|
||||
<p className="text-sm text-gray-500 font-medium mt-2 leading-relaxed px-2">
|
||||
{useRecovery
|
||||
? 'Enter a recovery code to continue.'
|
||||
: isEmail
|
||||
? 'Please enter the 6-digit verification code sent to your registered email address.'
|
||||
: 'Please enter the 6-digit authentication code from your authenticator app.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">
|
||||
{useRecovery ? 'Recovery Code' : 'Authentication Code'}
|
||||
<label className="block text-xs font-bold text-gray-400 uppercase tracking-widest mb-2.5">
|
||||
{useRecovery ? 'Recovery Code' : isEmail ? 'Email Verification Code' : 'Authenticator Code'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -42,35 +77,49 @@ export default function TwoFactorChallenge() {
|
||||
onChange={e => setData('code', e.target.value)}
|
||||
autoFocus
|
||||
className={`w-full h-12 border rounded-xl px-4 text-center font-mono font-bold text-lg tracking-[0.4em] outline-none transition-all
|
||||
${errors.code ? 'border-red-300 bg-red-50' : 'border-gray-200 focus:border-[#3D4E4B] focus:ring-2 focus:ring-[#3D4E4B]/10'}`}
|
||||
${errors.code ? 'border-red-300 bg-red-50 focus:ring-red-100' : 'border-gray-200 focus:border-[#3D4E4B] focus:ring-2 focus:ring-[#3D4E4B]/10'}`}
|
||||
placeholder={useRecovery ? 'xxxxxxxxxx-xxxxxxxxxx' : '000000'}
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="text-xs text-red-500 font-semibold mt-1.5">{errors.code}</p>
|
||||
<p className="text-xs text-red-500 font-semibold mt-1.5 text-center">{errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing || data.code.length < (useRecovery ? 5 : 6)}
|
||||
className="w-full h-11 bg-[#3D4E4B] text-white text-sm font-bold rounded-xl hover:bg-[#2D3A38] transition-all disabled:opacity-60"
|
||||
className="w-full h-11 bg-[#3D4E4B] text-white text-sm font-bold rounded-xl hover:bg-[#2D3A38] transition-all disabled:opacity-60 shadow-md shadow-[#3D4E4B]/15"
|
||||
>
|
||||
{processing ? 'Verifying...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => { setUseRecovery(!useRecovery); setData('code', ''); }}
|
||||
className="text-xs font-bold text-[#3D4E4B] hover:underline"
|
||||
>
|
||||
{useRecovery ? 'Use authenticator code instead' : 'Use a recovery code'}
|
||||
</button>
|
||||
</div>
|
||||
{isEmail && !useRecovery && (
|
||||
<div className="mt-5 text-center">
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="text-xs font-bold text-[#D4A017] hover:underline disabled:opacity-50"
|
||||
>
|
||||
{resending ? 'Resending code...' : "Didn't receive a code? Resend"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmail && (
|
||||
<div className="mt-6 text-center border-t border-gray-50 pt-4">
|
||||
<button
|
||||
onClick={() => { setUseRecovery(!useRecovery); setData('code', ''); }}
|
||||
className="text-xs font-bold text-[#3D4E4B] hover:underline"
|
||||
>
|
||||
{useRecovery ? 'Use authenticator code instead' : 'Use a recovery code'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a href="/login" className="text-xs font-semibold text-gray-400 hover:text-[#3D4E4B]">
|
||||
<a href="/login" className="text-xs font-semibold text-gray-400 hover:text-[#3D4E4B] transition-colors">
|
||||
← Back to login
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user