213 lines
8.8 KiB
TypeScript
213 lines
8.8 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } from 'lucide-react';
|
|
import { axiosInstance } from '../services/axios';
|
|
import { useAuthStore } from '../hooks/use-auth';
|
|
import { useToast } from '../hooks/use-toast';
|
|
|
|
export const InvitePage: React.FC = () => {
|
|
const { success } = useToast();
|
|
const [searchParams] = useSearchParams();
|
|
const token = searchParams.get('token');
|
|
const navigate = useNavigate();
|
|
const { setAuth } = useAuthStore();
|
|
|
|
const [status, setStatus] = useState<'loading' | 'valid' | 'invalid'>('loading');
|
|
const [email, setEmail] = useState<string>('');
|
|
const [password, setPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setStatus('invalid');
|
|
return;
|
|
}
|
|
|
|
// verify token on mount
|
|
const verifyToken = async () => {
|
|
try {
|
|
const response = await axiosInstance.get(`/auth/invite/${token}`);
|
|
setEmail(response.data.email);
|
|
setStatus('valid');
|
|
} catch (err) {
|
|
setStatus('invalid');
|
|
}
|
|
};
|
|
|
|
verifyToken();
|
|
}, [token]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (password !== confirmPassword) {
|
|
setError('Passwords do not match');
|
|
return;
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
setError('Password must be at least 8 characters long');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const response = await axiosInstance.post('/auth/invite/accept', {
|
|
token,
|
|
password
|
|
});
|
|
|
|
// Show success toast
|
|
success('Account activated successfully!');
|
|
|
|
// Set auth store
|
|
setAuth({ user: response.data.user, accessToken: response.data.token });
|
|
|
|
// Redirect based on role / status
|
|
setTimeout(() => {
|
|
if (response.data.user.role === 'ADMIN') {
|
|
navigate('/admin');
|
|
} else if (response.data.user.onboardingStatus === 'APPROVED') {
|
|
navigate('/client');
|
|
} else {
|
|
navigate('/onboarding');
|
|
}
|
|
}, 1000);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || err.response?.data?.error || 'Activation failed');
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (status === 'loading') {
|
|
return (
|
|
<div className="min-h-screen bg-ink-50 flex items-center justify-center">
|
|
<div className="w-8 h-8 border-4 border-ink-900/35 border-t-ink-900 rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === 'invalid') {
|
|
return (
|
|
<div className="min-h-screen bg-ink-50 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-md bg-ink-0 rounded-[2rem] p-8 border border-ink-200 shadow-2xl text-center">
|
|
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" />
|
|
</div>
|
|
<h2 className="text-2xl login-form-title font-bold text-ink-900 mb-2">Invalid or Expired Link</h2>
|
|
<p className="text-sm login-form-subtitle text-ink-500 max-w-md mx-auto mb-6">
|
|
The onboarding invitation link is invalid, expired, or has already been used. Please request a new invitation from your administrator.
|
|
</p>
|
|
<button onClick={() => navigate('/login')} className="text-ink-900 font-extrabold hover:underline">
|
|
Return to Login
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-ink-50 text-ink-900 font-sans flex items-center justify-center p-4">
|
|
{/* Monochromatic background accent */}
|
|
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" />
|
|
|
|
<div className="w-full max-w-md z-10">
|
|
<div className="text-center mb-6">
|
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-xl flex items-center justify-center mx-auto mb-6">
|
|
<Shield className="w-6 h-6 text-ink-0" />
|
|
</div>
|
|
<h1 className="text-3xl login-title font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
|
|
<p className="text-sm login-desc text-ink-500">
|
|
Set up your partner account for <span className="text-ink-900 font-bold">{email}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="bg-ink-0 rounded-[2rem] p-8 shadow-2xl border border-ink-200"
|
|
>
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
{error && (
|
|
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
|
|
<p className="text-xs font-bold text-red-600 dark:text-red-400">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wider text-ink-400">Set Password</label>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
<KeyRound className="w-4 h-4 text-ink-400" />
|
|
</div>
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
|
|
placeholder="Enter a secure password"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
|
|
>
|
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wider text-ink-400">Confirm Password</label>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
<CheckCircle className="w-4 h-4 text-ink-400" />
|
|
</div>
|
|
<input
|
|
type={showConfirmPassword ? 'text' : 'password'}
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
|
|
placeholder="Confirm your password"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
|
|
>
|
|
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting || !password || !confirmPassword}
|
|
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 hover:shadow-lg hover:-translate-y-0.5 transition-all disabled:opacity-50 disabled:hover:translate-y-0 mt-6"
|
|
>
|
|
{isSubmitting ? (
|
|
<div className="w-5 h-5 border-2 border-ink-0/30 border-t-ink-0 rounded-full animate-spin" />
|
|
) : (
|
|
<>
|
|
Create Account & Continue
|
|
<ChevronRight className="w-4 h-4" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
export default InvitePage;
|