emails
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
@@ -1,94 +1,202 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useMounted } from 'hooks/useMounted';
|
||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
export const Verify = () => {
|
export const Verify = () => {
|
||||||
|
const isMounted = useMounted();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useClient('/api/auth');
|
const client = useClient('/api/auth');
|
||||||
const { verify } = useAuth();
|
const { state, formRef, update } = useForm<VerifyFormState>({ email: '' });
|
||||||
const { state, formRef, update, isValid } = useForm<VerifyFormState>({}, validateForm);
|
|
||||||
|
|
||||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||||
|
const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('verify');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [verified, setVerified] = useState(false);
|
|
||||||
const [resending, setResending] = useState(false);
|
|
||||||
const [resent, setResent] = useState(false);
|
|
||||||
|
|
||||||
const handleResend = async () => {
|
const verifyToken = async () => {
|
||||||
if (!email || resending) return;
|
if (!verificationCode) return;
|
||||||
setResending(true);
|
|
||||||
try {
|
try {
|
||||||
await client.post('/resend-verification', { email });
|
const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode });
|
||||||
setResent(true);
|
if (data.ok) {
|
||||||
} catch (ex) {
|
setTokenStatus('valid');
|
||||||
const error = ex as { message?: string };
|
setFlow(data.flow);
|
||||||
toast.error(error.message || 'Failed to resend. Please try again.');
|
requestAnimationFrame(() => update({ email: data.email }));
|
||||||
} finally {
|
} else {
|
||||||
setResending(false);
|
setTokenStatus('invalid');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTokenStatus('invalid');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
// if (!verified) return;
|
if (!isMounted) return;
|
||||||
// const timer = setTimeout(() => navigate('/'), 3000);
|
if (!verificationCode) {
|
||||||
// return () => clearTimeout(timer);
|
setTokenStatus('invalid');
|
||||||
}, [verified]);
|
return;
|
||||||
|
}
|
||||||
|
verifyToken();
|
||||||
|
}, [isMounted]);
|
||||||
|
|
||||||
|
const isValid = validateForm(state);
|
||||||
|
|
||||||
|
const handleSubmit = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (flow === 'bootstrap') {
|
||||||
|
await client.post('/bootstrap', {
|
||||||
|
token: verificationCode,
|
||||||
|
name: state.name,
|
||||||
|
username: state.username,
|
||||||
|
password: state.password,
|
||||||
|
confirmPassword: state.confirmPassword,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await client.post('/verify', {
|
||||||
|
verificationCode,
|
||||||
|
name: state.name,
|
||||||
|
username: state.username,
|
||||||
|
password: state.password,
|
||||||
|
confirmPassword: state.confirmPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success('Account created. Please sign in.');
|
||||||
|
navigate('/');
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Failed to create account. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = tokenStatus === 'loading';
|
||||||
|
const invalid = tokenStatus === 'invalid';
|
||||||
|
const hideForm = loading || invalid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-dvh">
|
<>
|
||||||
|
{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-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Verify your account</div>
|
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||||
<div className="text-duck-dark/60">Enter your verification code</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">
|
||||||
|
{flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'}
|
||||||
|
</div>
|
||||||
|
<div className="text-duck-dark/60">
|
||||||
|
{flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'}
|
||||||
</div>
|
</div>
|
||||||
</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-background/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-background/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-background/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-background/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-background/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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||||
const { name, password, confirmPassword } = state;
|
const { name, username, password, confirmPassword } = state;
|
||||||
if (!name || !password || !confirmPassword) return false;
|
if (!name || !username || !password || !confirmPassword) return false;
|
||||||
if (password !== confirmPassword) return false;
|
if (password !== confirmPassword) return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VerifyFormState = {
|
type VerifyFormState = {
|
||||||
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
confirmPassword?: string;
|
confirmPassword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
template: 'VerifyAdmin',
|
template: 'VerifyAdmin',
|
||||||
subject: 'Verify your Officer account',
|
subject: 'Verify your officer.dev account',
|
||||||
to: email,
|
to: email,
|
||||||
data: { name: email, url },
|
data: { name: email, url },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
template: 'ForgotPassword',
|
template: 'ForgotPassword',
|
||||||
subject: 'Reset your Officer password',
|
subject: 'Reset your officer.dev password',
|
||||||
to: dbUser.email,
|
to: dbUser.email,
|
||||||
data: { email: dbUser.email, url },
|
data: { email: dbUser.email, url },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const resendVerificationHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
template: 'VerifyAdmin',
|
template: 'VerifyAdmin',
|
||||||
subject: 'Verify your Officer account',
|
subject: 'Verify your officer.dev account',
|
||||||
to: user.email,
|
to: user.email,
|
||||||
data: { name: user.email, url },
|
data: { name: user.email, url },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const signupHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
template: 'VerifyAdmin',
|
template: 'VerifyAdmin',
|
||||||
subject: 'Verify your Officer account',
|
subject: 'Verify your officer.dev account',
|
||||||
to: dbUser.email,
|
to: dbUser.email,
|
||||||
data: { name: dbUser.email, url },
|
data: { name: dbUser.email, url },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ smtpRouter.post('/test', async (ctx) => {
|
|||||||
'Authorization': `Bearer ${body.apiKey}`,
|
'Authorization': `Bearer ${body.apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ from, to: body.to, subject: 'Officer Test Email', html: testHtml }),
|
body: JSON.stringify({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
@@ -124,7 +124,7 @@ smtpRouter.post('/test', async (ctx) => {
|
|||||||
|
|
||||||
const url = buildTransportUrl(body);
|
const url = buildTransportUrl(body);
|
||||||
const transport = createTransport(url);
|
const transport = createTransport(url);
|
||||||
await transport.sendMail({ from, to: body.to, subject: 'Officer Test Email', html: testHtml });
|
await transport.sendMail({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml });
|
||||||
return ctx.json({ success: true });
|
return ctx.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ usersRouter.post('/invite', async (ctx) => {
|
|||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
template: 'UserInvite',
|
template: 'UserInvite',
|
||||||
subject: 'You have been invited to Officer',
|
subject: 'You have been invited to officer.dev',
|
||||||
to: email,
|
to: email,
|
||||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||||
});
|
});
|
||||||
@@ -81,7 +81,7 @@ usersRouter.post('/:id/resend-invite', async (ctx) => {
|
|||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
template: 'UserInvite',
|
template: 'UserInvite',
|
||||||
subject: 'You have been invited to Officer',
|
subject: 'You have been invited to officer.dev',
|
||||||
to: target.email,
|
to: target.email,
|
||||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const Email = ({ invitedBy, url }: EmailProps) => {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Container>
|
<Container>
|
||||||
<Text className="pt-4 text-2xl">You're invited to Officer</Text>
|
<Text className="pt-4 text-2xl">You're invited to officer.dev</Text>
|
||||||
<Text>You have been invited by {invitedBy || '<invitedBy>'} to join Officer.</Text>
|
<Text>You have been invited by {invitedBy || '<invitedBy>'} to join officer.dev.</Text>
|
||||||
<Text>Click the button below to set up your account.</Text>
|
<Text>Click the button below to set up your account.</Text>
|
||||||
<Button href={url || 'https://example.com'}>Accept Invitation</Button>
|
<Button href={url || 'https://example.com'}>Accept Invitation</Button>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const MainLayout = ({ children }: { children: ReactNode }) => {
|
|||||||
<Container className="rounded-lg bg-white p-8 shadow-lg">
|
<Container className="rounded-lg bg-white p-8 shadow-lg">
|
||||||
<Img
|
<Img
|
||||||
className="mx-auto block"
|
className="mx-auto block"
|
||||||
src="https://static.officer.dev/officer-logo.svg"
|
src="https://static.officer.dev/officer-logo.png"
|
||||||
width="240"
|
width="240"
|
||||||
alt="Officer Logo"
|
alt="Officer Logo"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user