diff --git a/public/officer-logo.png b/public/officer-logo.png new file mode 100644 index 00000000..14e38e8e Binary files /dev/null and b/public/officer-logo.png differ diff --git a/src/apps/officer-web/Screens/Authentication/Verify.tsx b/src/apps/officer-web/Screens/Authentication/Verify.tsx index 5a7e16ab..516998d9 100644 --- a/src/apps/officer-web/Screens/Authentication/Verify.tsx +++ b/src/apps/officer-web/Screens/Authentication/Verify.tsx @@ -1,94 +1,202 @@ 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 { Card } from '@/components/Card'; +import { cn } from '@/lib/utils'; +import { useMounted } from 'hooks/useMounted'; import { useForm } from 'hooks/useForm'; -import { useAuth } from 'hooks/useAuth'; import { useClient } from 'hooks/useClient'; export const Verify = () => { + const isMounted = useMounted(); const navigate = useNavigate(); const client = useClient('/api/auth'); - const { verify } = useAuth(); - const { state, formRef, update, isValid } = useForm({}, validateForm); + const { state, formRef, update } = useForm({ email: '' }); const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? ''); const [tokenStatus, setTokenStatus] = useState('loading'); + const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('verify'); 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); + const verifyToken = async () => { + if (!verificationCode) return; 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); + const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode }); + if (data.ok) { + setTokenStatus('valid'); + setFlow(data.flow); + requestAnimationFrame(() => update({ email: data.email })); + } else { + 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(() => { - // if (!verified) return; - // const timer = setTimeout(() => navigate('/'), 3000); - // return () => clearTimeout(timer); - }, [verified]); + if (!isMounted) return; + if (!verificationCode) { + setTokenStatus('invalid'); + 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 ( -
-
-
Verify your account
-
Enter your verification code
-
-
+ <> + {loading && ( + +
Verifying...
+
+ )} + + {invalid && ( + +
+
Invalid Link
+
This verification link is invalid or has expired.
+
+
+ )} + + +
+
+ {flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'} +
+
+ {flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'} +
+
+ +
+ + + + + + +
+ + + +
+ +
+ +
+
+
+ ); }; - const validateForm = (state: Partial) => { - const { name, password, confirmPassword } = state; - if (!name || !password || !confirmPassword) return false; + const { name, username, password, confirmPassword } = state; + if (!name || !username || !password || !confirmPassword) return false; if (password !== confirmPassword) return false; return true; }; type VerifyFormState = { + email?: string; name?: string; + username?: string; password?: string; confirmPassword?: string; }; + type TokenStatus = 'loading' | 'valid' | 'invalid'; diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index c4c84ade..11467c02 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -26,7 +26,7 @@ export const bootstrapHandler: Handler = async function (ctx) { await sendMail({ template: 'VerifyAdmin', - subject: 'Verify your Officer account', + subject: 'Verify your officer.dev account', to: email, data: { name: email, url }, }); diff --git a/src/servers/api/auth/forgot-password.ts b/src/servers/api/auth/forgot-password.ts index 29ce13ce..79c8d54e 100644 --- a/src/servers/api/auth/forgot-password.ts +++ b/src/servers/api/auth/forgot-password.ts @@ -15,7 +15,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) { await sendMail({ template: 'ForgotPassword', - subject: 'Reset your Officer password', + subject: 'Reset your officer.dev password', to: dbUser.email, data: { email: dbUser.email, url }, }); diff --git a/src/servers/api/auth/resend-verification.ts b/src/servers/api/auth/resend-verification.ts index 281c32e3..c8bee898 100644 --- a/src/servers/api/auth/resend-verification.ts +++ b/src/servers/api/auth/resend-verification.ts @@ -19,7 +19,7 @@ export const resendVerificationHandler: Handler = async function (ctx) { await sendMail({ template: 'VerifyAdmin', - subject: 'Verify your Officer account', + subject: 'Verify your officer.dev account', to: user.email, data: { name: user.email, url }, }); diff --git a/src/servers/api/auth/signup.ts b/src/servers/api/auth/signup.ts index c80f8a17..4207ab9e 100644 --- a/src/servers/api/auth/signup.ts +++ b/src/servers/api/auth/signup.ts @@ -27,7 +27,7 @@ export const signupHandler: Handler = async function (ctx) { await sendMail({ template: 'VerifyAdmin', - subject: 'Verify your Officer account', + subject: 'Verify your officer.dev account', to: dbUser.email, data: { name: dbUser.email, url }, }); diff --git a/src/servers/api/server-settings/smtp.ts b/src/servers/api/server-settings/smtp.ts index fc7bcc91..c63173a8 100644 --- a/src/servers/api/server-settings/smtp.ts +++ b/src/servers/api/server-settings/smtp.ts @@ -113,7 +113,7 @@ smtpRouter.post('/test', async (ctx) => { 'Authorization': `Bearer ${body.apiKey}`, '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) { const err = await res.json(); @@ -124,7 +124,7 @@ smtpRouter.post('/test', async (ctx) => { const url = buildTransportUrl(body); 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 }); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index e602f6d8..287c98a3 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -54,7 +54,7 @@ usersRouter.post('/invite', async (ctx) => { await sendMail({ template: 'UserInvite', - subject: 'You have been invited to Officer', + subject: 'You have been invited to officer.dev', to: email, data: { invitedBy: reqUser.name ?? reqUser.email, url }, }); @@ -81,7 +81,7 @@ usersRouter.post('/:id/resend-invite', async (ctx) => { await sendMail({ template: 'UserInvite', - subject: 'You have been invited to Officer', + subject: 'You have been invited to officer.dev', to: target.email, data: { invitedBy: reqUser.name ?? reqUser.email, url }, }); diff --git a/src/workspaces/emailer/emails/UserInvite.tsx b/src/workspaces/emailer/emails/UserInvite.tsx index 3e5c5002..b57a67b8 100644 --- a/src/workspaces/emailer/emails/UserInvite.tsx +++ b/src/workspaces/emailer/emails/UserInvite.tsx @@ -11,8 +11,8 @@ const Email = ({ invitedBy, url }: EmailProps) => { return ( - You're invited to Officer - You have been invited by {invitedBy || ''} to join Officer. + You're invited to officer.dev + You have been invited by {invitedBy || ''} to join officer.dev. Click the button below to set up your account. diff --git a/src/workspaces/emailer/emails/layouts/MainLayout.tsx b/src/workspaces/emailer/emails/layouts/MainLayout.tsx index 413667e0..cf071b30 100644 --- a/src/workspaces/emailer/emails/layouts/MainLayout.tsx +++ b/src/workspaces/emailer/emails/layouts/MainLayout.tsx @@ -10,7 +10,7 @@ const MainLayout = ({ children }: { children: ReactNode }) => { Officer Logo