refactored Authentication
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
export function ForgotPassword() {
|
||||
const client = useClient('/api/auth');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const { formRef, state, isValid } = useForm<ForgotPasswordFormState>({}, (s) => !!s.email);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await client.post('/forgot-password', { email: state.email!.trim() });
|
||||
setDone(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
<div className="text-sm text-duck-dark/60">
|
||||
<p>
|
||||
A password reset link has been sent to <strong>{state.email}</strong>.
|
||||
</p>
|
||||
<p>Click it to reset your password.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
|
||||
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-duck-dark/60">
|
||||
<p>
|
||||
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Back to login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type ForgotPasswordFormState = {
|
||||
email?: string;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useResetPassword } from './useResetPassword';
|
||||
|
||||
export function ResetPassword() {
|
||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
const hideform = loading || invalid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||
<div className="text-duck-dark/60">Enter your new password</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ForgotPassword } from './ForgotPassword';
|
||||
export { ResetPassword } from './ResetPassword';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export const useResetPassword = () => {
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { state, formRef, isValid } = useForm<ResetPasswordFormState>({}, validateForm);
|
||||
|
||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const verifyToken = async () => {
|
||||
if (!verificationCode) return;
|
||||
try {
|
||||
const data = await client.post<{ ok: boolean }>('/verify-token', { verificationCode });
|
||||
setTokenStatus(data.ok ? 'valid' : 'invalid');
|
||||
} catch {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
if (!verificationCode) {
|
||||
setTokenStatus('invalid');
|
||||
return;
|
||||
}
|
||||
verifyToken();
|
||||
}, [isMounted]);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await client.post('/reset-password', {
|
||||
password: state.password,
|
||||
verificationCode,
|
||||
});
|
||||
toast.success('Password reset successfully. Please sign in.');
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to reset password. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { formRef, isValid, tokenStatus, isSubmitting, handleSubmit };
|
||||
};
|
||||
|
||||
type ResetPasswordFormState = {
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
|
||||
const validateForm = (state: Partial<ResetPasswordFormState>) => {
|
||||
const { password, confirmPassword } = state;
|
||||
if (!password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
|
||||
export function Bootstrap() {
|
||||
const authClient = useClient('/api/auth');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const { formRef, state, isValid } = useForm({}, (state) => !!state.email);
|
||||
|
||||
const handleBootstrap = async () => {
|
||||
const trimmed = state.email.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await authClient.post('/bootstrap', { email: trimmed });
|
||||
setDone(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Bootstrap failed. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
<div className="text-sm text-duck-dark/60">
|
||||
<p>A verification link has been sent to <strong>{state.email}</strong>.</p>
|
||||
<p>Click it to activate your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Welcome, Admin</h2>
|
||||
<p className="text-sm text-duck-dark/60 mb-6">Enter your email to create your administrator account.</p>
|
||||
<form ref={formRef} className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="admin@example.com"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border-2 border-duck-dark/20 text-sm text-duck-dark placeholder:!text-duck-dark/30 focus:outline-none focus:border-duck-teal/50"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleBootstrap}
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-6 py-2.5 rounded-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Bootstrap'}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { OfficerSvgLogo } from '@/components/Logos';
|
||||
import { useLandingPage } from '@/state/useLandingPage';
|
||||
import { Bootstrap } from './Bootstrap';
|
||||
import { Login } from './Login';
|
||||
|
||||
export function LandingPage() {
|
||||
const { registrationOpen, isLoading } = useLandingPage();
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||
<header className="pt-12 md:pt-20 lg:pt-24 xl:pt-0 z-20 flex justify-center">
|
||||
<div className="hidden md:block md:pt-28">
|
||||
<OfficerSvgLogo size="2xl" />
|
||||
</div>
|
||||
<div className="block md:hidden">
|
||||
<OfficerSvgLogo size="lg" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-0 md:pb-12">
|
||||
{registrationOpen ? <Bootstrap /> : <Login />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<LoginFormState>({}, validateForm);
|
||||
const { signin } = useAuth();
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await signin({ email: state.email!, password: state.password! });
|
||||
window.location.href = '/';
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Login failed. Please try again.');
|
||||
update({ ...state, password: '' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-duck-dark/60 grid gap-1">
|
||||
{/* <p> */}
|
||||
{/* Don't have an account?{' '} */}
|
||||
{/* <Link to="/auth/register" className="text-duck-forest underline hover:text-duck-forest/80"> */}
|
||||
{/* Register */}
|
||||
{/* </Link> */}
|
||||
{/* </p> */}
|
||||
<p>
|
||||
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type LoginFormState = {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<LoginFormState>) => {
|
||||
const { email, password } = state;
|
||||
if (!email || !password) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './LandingPage';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Background } from './Background';
|
||||
type AuthenticationLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||
return (
|
||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
<Background />
|
||||
<div className="absolute inset-0 z-520">
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DuckAvatar } from "./DuckAvatar";
|
||||
import { PixelGrid } from "@/components/PixelGrid";
|
||||
import landscapebg from './landscape1.jpg';
|
||||
|
||||
export function Background() {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage: `url(${landscapebg})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center center',
|
||||
}}
|
||||
>
|
||||
<PixelGrid />
|
||||
<DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
type DebugPanelProps = {
|
||||
fullControlMode: boolean;
|
||||
cameraInfo: {
|
||||
cameraPosition: number[];
|
||||
target: number[];
|
||||
zoom: number;
|
||||
};
|
||||
modelInfo: {
|
||||
position: number[];
|
||||
rotation: number[];
|
||||
scale: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
||||
return (
|
||||
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
||||
<div className="font-bold text-blue-400">Controls:</div>
|
||||
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
||||
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
||||
</div>
|
||||
<div>* Left-click/Touch + drag: Rotate</div>
|
||||
{fullControlMode && (
|
||||
<>
|
||||
<div>* Right-click + drag: Pan</div>
|
||||
<div>* Scroll: Zoom</div>
|
||||
</>
|
||||
)}
|
||||
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
||||
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
||||
|
||||
<div className="font-bold text-green-400 pt-2">Camera:</div>
|
||||
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
||||
|
||||
<div className="font-bold text-yellow-400 pt-2">Model:</div>
|
||||
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Canvas, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { DuckModel } from './DuckModel';
|
||||
import { DebugPanel } from './DebugPanel';
|
||||
|
||||
type CameraInfoProps = {
|
||||
onUpdate: (info: { cameraPosition: number[]; target: number[]; zoom: number }) => void;
|
||||
initialTarget: [number, number, number];
|
||||
fullControlMode: boolean;
|
||||
initialPosition: [number, number, number];
|
||||
};
|
||||
|
||||
function CameraInfo({ onUpdate, initialTarget, fullControlMode, initialPosition }: CameraInfoProps) {
|
||||
const { camera } = useThree();
|
||||
const controlsRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.target.set(...initialTarget);
|
||||
camera.position.set(...initialPosition);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
}, [initialTarget, initialPosition, camera]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (controlsRef.current) {
|
||||
const controls = controlsRef.current;
|
||||
onUpdate({
|
||||
cameraPosition: camera.position.toArray(),
|
||||
target: controls.target.toArray(),
|
||||
zoom: camera.zoom,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [camera, onUpdate]);
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
enabled={fullControlMode}
|
||||
enableRotate={fullControlMode}
|
||||
enableZoom={fullControlMode}
|
||||
enablePan={fullControlMode}
|
||||
target={initialTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DuckAvatarProps = {
|
||||
showDebug: boolean;
|
||||
fullControlMode: boolean;
|
||||
currentSection: number;
|
||||
};
|
||||
|
||||
export function DuckAvatar({ showDebug, fullControlMode, currentSection }: DuckAvatarProps) {
|
||||
const isHeroSection = currentSection === 0;
|
||||
const [cameraInfo, setCameraInfo] = useState({
|
||||
cameraPosition: [2.89, 5.24, 7.38],
|
||||
target: [0.3, 3.29, -0.4],
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
const [modelInfo, setModelInfo] = useState({
|
||||
position: [0, -0.2, 0],
|
||||
rotation: [-0.1, -0.75, 0],
|
||||
scale: 4.5,
|
||||
});
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const heroClasses = 'fixed top-12 md:top-auto md:bottom-0 left-1/2 -translate-x-1/2 w-[60vh] h-[75vh]';
|
||||
const miniClasses = 'fixed bottom-4 right-4 w-[20vh] h-[25vh]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isHeroSection ? heroClasses : miniClasses} overflow-visible z-1 pointer-events-none`}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full pointer-events-none overflow-visible" style={{ background: 'transparent' }}>
|
||||
<Canvas
|
||||
camera={{
|
||||
position: [2.89, 5.24, 7.38],
|
||||
fov: 65,
|
||||
near: 0.1,
|
||||
far: 1000,
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<ambientLight intensity={1.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={2} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={1} />
|
||||
<pointLight position={[0, 5, 0]} intensity={1.5} />
|
||||
<CameraInfo
|
||||
onUpdate={setCameraInfo}
|
||||
initialTarget={[0.3, 3.29, -0.4]}
|
||||
fullControlMode={fullControlMode}
|
||||
initialPosition={[2.89, 5.24, 7.38]}
|
||||
/>
|
||||
<DuckModel
|
||||
onUpdate={setModelInfo}
|
||||
onAssetsLoaded={() => {
|
||||
setIsLoaded(true);
|
||||
}}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
{showDebug && <DebugPanel fullControlMode={fullControlMode} cameraInfo={cameraInfo} modelInfo={modelInfo} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useGLTF, useAnimations } from '@react-three/drei';
|
||||
|
||||
type DuckModelProps = {
|
||||
onUpdate: (info: { position: number[]; rotation: number[]; scale: number }) => void;
|
||||
onAssetsLoaded: () => void;
|
||||
currentSection: number;
|
||||
};
|
||||
|
||||
export function DuckModel({ onUpdate, onAssetsLoaded, currentSection }: DuckModelProps) {
|
||||
const character = useGLTF('/static/duck3D/Character_output.glb');
|
||||
const animations = useGLTF('/static/duck3D/Meshy_Merged_Animations.glb');
|
||||
const meshRef = useRef<any>(null);
|
||||
const { actions } = useAnimations(animations.animations, meshRef);
|
||||
|
||||
useEffect(() => {
|
||||
onAssetsLoaded();
|
||||
}, [character, animations, onAssetsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actions) return;
|
||||
|
||||
const isContactSection = currentSection === 6;
|
||||
|
||||
if (isContactSection) {
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(0.4));
|
||||
return;
|
||||
}
|
||||
|
||||
const action = actions['Walking'];
|
||||
if (!action) return;
|
||||
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(0.4));
|
||||
|
||||
action.reset();
|
||||
action.setLoop(2201, Infinity);
|
||||
action.clampWhenFinished = false;
|
||||
action.fadeIn(0.4);
|
||||
action.play();
|
||||
}, [actions, currentSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (meshRef.current) {
|
||||
onUpdate({
|
||||
position: meshRef.current.position.toArray(),
|
||||
rotation: meshRef.current.rotation.toArray().slice(0, 3),
|
||||
scale: meshRef.current.scale.x,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [onUpdate]);
|
||||
|
||||
const isContactSection = currentSection === 6;
|
||||
const rotationY = isContactSection ? 0 : (10 * Math.PI) / 180;
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={meshRef}
|
||||
object={character.scene}
|
||||
position={[0, -0.2, 0]}
|
||||
scale={4.5}
|
||||
rotation={[-0.1, rotationY, 0]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DuckAvatar';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AuthenticationLayout';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
export const SignoutScreen = () => {
|
||||
const { signout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const logout = async () => {
|
||||
await signout();
|
||||
window.location.href = '/';
|
||||
};
|
||||
logout();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export const Verify = () => {
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { verify } = useAuth();
|
||||
const { state, formRef, update, isValid } = useForm<VerifyFormState>({}, validateForm);
|
||||
|
||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [resent, setResent] = useState(false);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email || resending) return;
|
||||
setResending(true);
|
||||
try {
|
||||
await client.post('/resend-verification', { email });
|
||||
setResent(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to resend. Please try again.');
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// if (!open || !verificationCode) {
|
||||
// if (!verificationCode) setTokenStatus('invalid');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// window.history.replaceState(null, '', '/auth/verify');
|
||||
//
|
||||
// client
|
||||
// .post<{ email: string }>('/verify-token', { verificationCode })
|
||||
// .then((data) => {
|
||||
// setEmail(data.email);
|
||||
// setTokenStatus('valid');
|
||||
// })
|
||||
// .catch(() => {
|
||||
// // Try to decode email from expired token for resend
|
||||
// try {
|
||||
// const payload = JSON.parse(atob(verificationCode.split('.')[1]!));
|
||||
// if (payload.email) setEmail(payload.email);
|
||||
// } catch {
|
||||
// // ignore
|
||||
// }
|
||||
// setTokenStatus('invalid');
|
||||
// });
|
||||
}, [open]);
|
||||
|
||||
// Redirect after successful verification
|
||||
useEffect(() => {
|
||||
// if (!verified) return;
|
||||
// const timer = setTimeout(() => navigate('/'), 3000);
|
||||
// return () => clearTimeout(timer);
|
||||
}, [verified]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-dvh">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Verify your account</div>
|
||||
<div className="text-duck-dark/60">Enter your verification code</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||
const { name, password, confirmPassword } = state;
|
||||
if (!name || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type VerifyFormState = {
|
||||
name?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useVerifyScreen } from './useVerifyScreen';
|
||||
|
||||
export const VerifyScreen = () => {
|
||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useVerifyScreen();
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
const hideform = loading || invalid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Create Your Account</div>
|
||||
<div className="text-duck-dark/60">Set up your administrator profile</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Username</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="your-username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card >
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './VerifyScreen';
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export const useVerifyScreen = () => {
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { state, formRef, update, isValid } = useForm<VerifyFormState>({ email: '' }, validateForm);
|
||||
|
||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const verifyToken = async () => {
|
||||
if (!verificationCode) return;
|
||||
try {
|
||||
const data = await client.post<{ ok: boolean; email: string }>('/verify-token', { verificationCode });
|
||||
if (data.ok) {
|
||||
setTokenStatus('valid');
|
||||
requestAnimationFrame(() => update({ email: data.email }));
|
||||
} else {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
} catch {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
if (!verificationCode) {
|
||||
setTokenStatus('invalid');
|
||||
return;
|
||||
}
|
||||
verifyToken();
|
||||
}, [isMounted]);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await client.post('/bootstrap', {
|
||||
token: verificationCode,
|
||||
name: state.name,
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
toast.success('Account created. Please sign in.');
|
||||
navigate('/auth/login');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to create account. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { formRef, state, isValid, tokenStatus, isSubmitting, handleSubmit };
|
||||
};
|
||||
|
||||
type VerifyFormState = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
|
||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||
const { name, username, password, confirmPassword } = state;
|
||||
if (!name || !username || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AuthenticationLayout } from './Layout';
|
||||
import { LandingPage } from './LandingPage';
|
||||
import { SignoutScreen } from './Signout';
|
||||
import { VerifyScreen } from './VerifyScreen';
|
||||
import { ForgotPassword, ResetPassword } from './ForgotPassword';
|
||||
|
||||
export {
|
||||
AuthenticationLayout,
|
||||
LandingPage,
|
||||
SignoutScreen,
|
||||
VerifyScreen,
|
||||
ForgotPassword,
|
||||
ResetPassword,
|
||||
};
|
||||
Reference in New Issue
Block a user