refactored Authentication
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
import { AuthLayout } from './Layout';
|
|
||||||
import { Hero } from './components/Hero';
|
|
||||||
|
|
||||||
export function LandingPage() {
|
|
||||||
return (
|
|
||||||
<AuthLayout>
|
|
||||||
<Hero />
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { PixelGrid } from '../../../../workspaces/components/PixelGrid';
|
|
||||||
import { DuckAvatar } from './components/DuckAvatar';
|
|
||||||
import { SignupModal } from './components/SignupModal';
|
|
||||||
import { LoginModal } from './components/LoginModal';
|
|
||||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
|
||||||
import { VerifyModal } from './components/VerifyModal';
|
|
||||||
import { ResetPasswordModal } from './components/ResetPasswordModal';
|
|
||||||
|
|
||||||
type AuthLayoutProps = {
|
|
||||||
children?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { registrationOpen } = useAuth();
|
|
||||||
|
|
||||||
const signupOpen = registrationOpen && location.pathname === '/auth/register';
|
|
||||||
const loginOpen = location.pathname === '/auth/login';
|
|
||||||
const forgotPasswordOpen = location.pathname === '/auth/forgot-password';
|
|
||||||
const verifyOpen = location.pathname === '/auth/verify';
|
|
||||||
const resetPasswordOpen = location.pathname === '/auth/reset-password';
|
|
||||||
|
|
||||||
const closeModal = (open: boolean) => !open && navigate('/');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative overflow-hidden h-dvh outline-none fixed inset-0">
|
|
||||||
<PixelGrid />
|
|
||||||
<DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />
|
|
||||||
|
|
||||||
<section className="relative h-dvh snap-start overflow-hidden">
|
|
||||||
{/* Background layer */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'var(--page-bg-image)',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Content layer - above duck and pixel grid */}
|
|
||||||
<div className="absolute inset-0 z-[520]">{children}</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<SignupModal open={signupOpen} onOpenChange={closeModal} />
|
|
||||||
<LoginModal open={loginOpen} onOpenChange={closeModal} />
|
|
||||||
<ForgotPasswordModal open={forgotPasswordOpen} onOpenChange={closeModal} />
|
|
||||||
<VerifyModal open={verifyOpen} onOpenChange={closeModal} />
|
|
||||||
<ResetPasswordModal open={resetPasswordOpen} onOpenChange={closeModal} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
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 { Link } from 'react-router';
|
|
||||||
import { PaperDialog } from '@/components/Dialogs';
|
|
||||||
import { useForm } from 'hooks/useForm';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
|
|
||||||
type ForgotPasswordFormState = {
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<ForgotPasswordFormState>) => {
|
|
||||||
return !!state.email;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ForgotPasswordModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ForgotPasswordModal = ({ open, onOpenChange }: ForgotPasswordModalProps) => {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [sent, setSent] = useState(false);
|
|
||||||
const { state, formRef, update, isValid } = useForm<ForgotPasswordFormState>({}, validateForm);
|
|
||||||
const { forgotPassword } = useAuth();
|
|
||||||
|
|
||||||
const handleSubmit = async (ev: React.FormEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!isValid || isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await forgotPassword({ email: state.email! });
|
|
||||||
setSent(true);
|
|
||||||
} catch (ex) {
|
|
||||||
const error = ex as { message?: string };
|
|
||||||
toast.error(error.message || 'Failed to send recovery email. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setSent(false);
|
|
||||||
if (formRef.current) {
|
|
||||||
update({ email: '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onOpenChange(isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled = !isValid || isSubmitting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaperDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={handleClose}
|
|
||||||
onOpenAutoFocus={(ev) => {
|
|
||||||
if (!sent) {
|
|
||||||
const firstInput = (ev.currentTarget as HTMLElement | null)?.querySelector('input');
|
|
||||||
firstInput?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sent ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark">Email Sent</h2>
|
|
||||||
<p className="mt-4 text-duck-dark/70">Please check your email for further instructions.</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleClose(false)}
|
|
||||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Forgot Password</DialogTitle>
|
|
||||||
<DialogDescription className="text-duck-dark/60">
|
|
||||||
Enter your email and we'll send you a recovery link
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<DialogFooter className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}
|
|
||||||
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 Recovery Email'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-duck-dark/60">
|
|
||||||
Remember your password?{' '}
|
|
||||||
<Link to="/auth/login" className="text-duck-forest underline hover:text-duck-forest/80">
|
|
||||||
Sign In
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { OfficerSvgLogo } from '../Logos';
|
|
||||||
|
|
||||||
export function Hero() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { registrationOpen, signup } = useAuth();
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
|
|
||||||
const handleBootstrap = async () => {
|
|
||||||
const trimmed = email.trim();
|
|
||||||
if (!trimmed || isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await signup({ 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 (
|
|
||||||
<>
|
|
||||||
<header className="pt-28 md:pt-20 lg:pt-24 xl:pt-0 z-20 flex justify-center">
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<OfficerSvgLogo size="2xl" />
|
|
||||||
</div>
|
|
||||||
<div className="block md:hidden">
|
|
||||||
<OfficerSvgLogo size="lg" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{registrationOpen ? (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
|
||||||
<div className="bg-white/90 border-2 border-duck-dark/20 rounded-2xl p-8 max-w-md w-full mx-4 shadow-xl">
|
|
||||||
{done ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
|
||||||
<p className="text-sm text-duck-dark/60">
|
|
||||||
A verification link has been sent to <strong>{email}</strong>. Click it to activate your account.
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(ev) => setEmail(ev.target.value)}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleBootstrap();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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={!email.trim() || 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>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-20">
|
|
||||||
<Button
|
|
||||||
size="default"
|
|
||||||
onClick={() => navigate('/auth/login')}
|
|
||||||
className="bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow rounded-lg font-bold transition-all duration-200 hover:scale-105 cursor-pointer px-12 py-5 text-lg md:text-xl lg:text-2xl"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './Hero';
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
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 { Link } from 'react-router';
|
|
||||||
import { PaperDialog } from '@/components/Dialogs';
|
|
||||||
import { useForm } from 'hooks/useForm';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
|
|
||||||
type LoginFormState = {
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<LoginFormState>) => {
|
|
||||||
const { email, password } = state;
|
|
||||||
if (!email || !password) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LoginModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoginModal = ({ open, onOpenChange }: LoginModalProps) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
if (formRef.current) {
|
|
||||||
update({ email: '', password: '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onOpenChange(isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled = !isValid || isSubmitting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaperDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={handleClose}
|
|
||||||
onOpenAutoFocus={(ev) => {
|
|
||||||
const firstInput = (ev.currentTarget as HTMLElement | null)?.querySelector('input');
|
|
||||||
firstInput?.focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Welcome Back</DialogTitle>
|
|
||||||
<DialogDescription className="text-duck-dark/60">Sign in to your account</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<DialogFooter className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}
|
|
||||||
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>
|
|
||||||
</DialogFooter>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
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 { Link } from 'react-router';
|
|
||||||
import { PaperDialog } from '@/components/Dialogs';
|
|
||||||
import { useForm } from 'hooks/useForm';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
|
|
||||||
type ResetPasswordFormState = {
|
|
||||||
password?: string;
|
|
||||||
confirmPassword?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<ResetPasswordFormState>) => {
|
|
||||||
const { password, confirmPassword } = state;
|
|
||||||
if (!password || !confirmPassword) return false;
|
|
||||||
if (password !== confirmPassword) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResetPasswordModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ResetPasswordModal = ({ open, onOpenChange }: ResetPasswordModalProps) => {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [reset, setReset] = useState(false);
|
|
||||||
const { state, formRef, update, isValid } = useForm<ResetPasswordFormState>({}, validateForm);
|
|
||||||
const { resetPassword } = useAuth();
|
|
||||||
|
|
||||||
const handleSubmit = async (ev: React.FormEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!isValid || isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const verificationCode = new URL(window.location.href).searchParams.get('verificationCode');
|
|
||||||
if (!verificationCode) throw new Error('Invalid verification code');
|
|
||||||
await resetPassword({ password: state.password!, verificationCode });
|
|
||||||
setReset(true);
|
|
||||||
} catch (ex) {
|
|
||||||
const error = ex as { message?: string };
|
|
||||||
toast.error(error.message || 'Password reset failed. Please try again.');
|
|
||||||
update({ password: '', confirmPassword: '' });
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setReset(false);
|
|
||||||
if (formRef.current) {
|
|
||||||
update({ password: '', confirmPassword: '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onOpenChange(isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled = !isValid || isSubmitting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaperDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={handleClose}
|
|
||||||
onOpenAutoFocus={(ev) => {
|
|
||||||
if (!reset) {
|
|
||||||
const firstInput = (ev.currentTarget as HTMLElement | null)?.querySelector('input');
|
|
||||||
firstInput?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{reset ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark">Password Reset</h2>
|
|
||||||
<p className="mt-4 text-duck-dark/70">Your password has been reset. You can now sign in.</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => (window.location.href = '/auth/login')}
|
|
||||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Reset Password</DialogTitle>
|
|
||||||
<DialogDescription className="text-duck-dark/60">Enter your new password</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">New 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 chars, mixed case, number, symbol"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<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="Repeat your password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<DialogFooter className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}
|
|
||||||
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>
|
|
||||||
</DialogFooter>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-duck-dark/60">
|
|
||||||
<Link to="/auth/login" className="text-duck-forest underline hover:text-duck-forest/80">
|
|
||||||
Back to Sign In
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useLocation, Link } from 'react-router';
|
|
||||||
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 { PaperDialog } from '@/components/Dialogs';
|
|
||||||
import { useForm } from 'hooks/useForm';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
|
|
||||||
type SignupFormState = {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
confirmPassword?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<SignupFormState>) => {
|
|
||||||
const { name, email, password, confirmPassword } = state;
|
|
||||||
if (!name || !email || !password || !confirmPassword) return false;
|
|
||||||
if (password !== confirmPassword) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SignupModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SignupModal = ({ open, onOpenChange }: SignupModalProps) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const bootstrapEmail = (location.state as { email?: string } | null)?.email ?? '';
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [registered, setRegistered] = useState(false);
|
|
||||||
const { state, formRef, update, isValid } = useForm<SignupFormState>({ email: bootstrapEmail }, validateForm);
|
|
||||||
const { signup } = useAuth();
|
|
||||||
|
|
||||||
const handleSubmit = async (ev: React.FormEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!isValid || isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await signup({ name: state.name, email: state.email, password: state.password });
|
|
||||||
setRegistered(true);
|
|
||||||
} catch (ex) {
|
|
||||||
const error = ex as { message?: string };
|
|
||||||
toast.error(error.message || 'Signup failed. Please try again.');
|
|
||||||
update({ ...state, password: '', confirmPassword: '' });
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setRegistered(false);
|
|
||||||
if (formRef.current) {
|
|
||||||
update({ name: '', email: '', password: '', confirmPassword: '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onOpenChange(isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled = !isValid || isSubmitting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaperDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={handleClose}
|
|
||||||
onOpenAutoFocus={(ev) => {
|
|
||||||
if (!registered) {
|
|
||||||
const firstInput = (ev.currentTarget as HTMLElement).querySelector('input');
|
|
||||||
firstInput?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{registered ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark">Registration Successful</h2>
|
|
||||||
<p className="mt-4 text-duck-dark/70">Please check your email to verify your account.</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleClose(false)}
|
|
||||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Create Account</DialogTitle>
|
|
||||||
<DialogDescription className="text-duck-dark/60">Create a new account to get started</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
|
||||||
<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">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"
|
|
||||||
defaultValue={bootstrapEmail}
|
|
||||||
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="Min 12 chars, mixed case, number, symbol"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<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="Repeat your password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<DialogFooter className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}
|
|
||||||
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...' : 'Sign Up'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-duck-dark/60">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link to="/auth/login" className="text-duck-forest underline hover:text-duck-forest/80">
|
|
||||||
Sign In
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
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 { PaperDialog } from '@/components/Dialogs';
|
|
||||||
import { useForm } from 'hooks/useForm';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
|
|
||||||
type VerifyFormState = {
|
|
||||||
name?: string;
|
|
||||||
password?: string;
|
|
||||||
confirmPassword?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
|
||||||
const { name, password, confirmPassword } = state;
|
|
||||||
if (!name || !password || !confirmPassword) return false;
|
|
||||||
if (password !== confirmPassword) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VerifyModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
|
||||||
|
|
||||||
export const VerifyModal = ({ open, onOpenChange }: VerifyModalProps) => {
|
|
||||||
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 [email, setEmail] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [verified, setVerified] = useState(false);
|
|
||||||
const [resending, setResending] = useState(false);
|
|
||||||
const [resent, setResent] = useState(false);
|
|
||||||
|
|
||||||
// Validate token on mount and strip it from the URL
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSubmit = async (ev: React.FormEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!isValid || isSubmitting || !verificationCode) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await verify({
|
|
||||||
verificationCode,
|
|
||||||
name: state.name,
|
|
||||||
password: state.password,
|
|
||||||
confirmPassword: state.confirmPassword,
|
|
||||||
});
|
|
||||||
setVerified(true);
|
|
||||||
} catch (ex) {
|
|
||||||
const error = ex as { message?: string };
|
|
||||||
toast.error(error.message || 'Verification failed. Please try again.');
|
|
||||||
update({ ...state, password: '', confirmPassword: '' });
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (tokenStatus === 'loading') {
|
|
||||||
return (
|
|
||||||
<PaperDialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Validating...</DialogTitle>
|
|
||||||
<DialogDescription className="text-duck-dark/60">Please wait</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
</div>
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid / expired token
|
|
||||||
if (tokenStatus === 'invalid') {
|
|
||||||
return (
|
|
||||||
<PaperDialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark">Link Expired</h2>
|
|
||||||
<p className="mt-4 text-duck-dark/70">
|
|
||||||
This verification link is no longer valid.{' '}
|
|
||||||
{email ? 'Click below to receive a new one.' : 'Please request a new one.'}
|
|
||||||
</p>
|
|
||||||
{resent ? (
|
|
||||||
<p className="mt-6 text-sm text-duck-teal font-medium">
|
|
||||||
A new verification email has been sent to <strong>{email}</strong>.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
email && (
|
|
||||||
<Button
|
|
||||||
onClick={handleResend}
|
|
||||||
disabled={resending}
|
|
||||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{resending ? 'Sending...' : 'Resend Verification Email'}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid token — show form or success
|
|
||||||
return (
|
|
||||||
<PaperDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
onOpenAutoFocus={(ev) => {
|
|
||||||
const firstInput = (ev.currentTarget as HTMLElement).querySelector('input:not([disabled])');
|
|
||||||
(firstInput as HTMLElement)?.focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{verified ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark">Account Verified</h2>
|
|
||||||
<p className="mt-4 text-duck-dark/70">
|
|
||||||
Congratulations! Your account has been set up successfully. Redirecting...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Set Up Your Account</DialogTitle>
|
|
||||||
<DialogDescription className="text-duck-dark/60">Complete your account details</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<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-duck-dark/5 border-duck-dark/10 text-duck-dark disabled:opacity-100"
|
|
||||||
type="email"
|
|
||||||
value={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">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 chars, mixed case, number, symbol"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<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="Repeat your password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<DialogFooter 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 ? 'Verifying...' : 'Verify'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PaperDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { LandingPage } from './LandingPage';
|
|
||||||
export { AuthLayout } from './Layout';
|
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
declare module '*.jpg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
Vendored
-20
@@ -1,23 +1,3 @@
|
|||||||
declare module '*.jpg' {
|
|
||||||
const src: string;
|
|
||||||
export default src;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.jpeg' {
|
|
||||||
const src: string;
|
|
||||||
export default src;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.png' {
|
|
||||||
const src: string;
|
|
||||||
export default src;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.svg' {
|
|
||||||
const src: string;
|
|
||||||
export default src;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
type SelectOption = { value: number | string; label?: string; href?: string; hidden?: boolean };
|
type SelectOption = { value: number | string; label?: string; href?: string; hidden?: boolean };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user