Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions client/bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.5.3",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-router": "^1.168.7",
Expand Down
4 changes: 2 additions & 2 deletions client/src/features/auth/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { queryClient } from "../../main";

// Login
export const useSignin = () => useMutation({
mutationFn: async (data: { email: string; password: string; twoFactorCode?: string }) => {
mutationFn: async (data: { email: string; password: string; twoFactorCode?: string; turnstileToken?: string | null }) => {
const response = await axiosInstance.post("/auth/login", data);
return response.data;
},
});

// Register
export const useSignup = () => useMutation({
mutationFn: async (data: { email: string; password: string; username: string }) => {
mutationFn: async (data: { email: string; password: string; username: string; turnstileToken?: string | null }) => {
const response = await axiosInstance.post("/auth/register", data);
return response.data;
},
Expand Down
7 changes: 5 additions & 2 deletions client/src/features/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ export const LoginPage = () => {
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [twoFactorStep, setTwoFactorStep] = useState(false);
const [credentials, setCredentials] = useState<{ email: string; password: string } | null>(null);
const [turnstileReset, setTurnstileReset] = useState(0);

const handleSubmit = (values: { email: string; password: string; remember: boolean }) => {
const handleSubmit = (values: { email: string; password: string; remember: boolean; turnstileToken: string | null }) => {
setErrorMsg(null);
signin(
{ email: values.email, password: values.password },
{ email: values.email, password: values.password, turnstileToken: values.turnstileToken },
{
onSuccess: data => {
if (data?.twoFactorRequired) {
Expand All @@ -30,6 +31,7 @@ export const LoginPage = () => {
navigate({ to: "/" });
},
onError: error => {
setTurnstileReset(n => n + 1);
const e = error as { response?: { data?: { message?: string } } };
setErrorMsg(e.response?.data?.message ?? "Invalid email or password. Please try again.");
},
Expand Down Expand Up @@ -86,6 +88,7 @@ export const LoginPage = () => {
errorMsg={errorMsg}
onSubmit={handleSubmit}
onValuesChange={() => setErrorMsg(null)}
resetSignal={turnstileReset}
/>
)}
</div>
Expand Down
14 changes: 10 additions & 4 deletions client/src/features/auth/login/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useState } from "react";
import { Button, Checkbox, Form, Input } from "antd";
import { Link } from "@tanstack/react-router";
import { TurnstileWidget, turnstileEnabled } from "../shared/TurnstileWidget";

interface Props {
loading: boolean;
errorMsg: string | null;
onSubmit: (values: { email: string; password: string; remember: boolean }) => void;
onSubmit: (values: { email: string; password: string; remember: boolean; turnstileToken: string | null }) => void;
onValuesChange: () => void;
resetSignal?: number;
}

const GoogleSvg = () => (
Expand Down Expand Up @@ -33,8 +36,9 @@ const labelStyle = {
letterSpacing: "0.05em",
};

export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props) => {
export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange, resetSignal }: Props) => {
const [form] = Form.useForm();
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);

return (
<>
Expand All @@ -52,7 +56,7 @@ export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props
<Form
form={form}
layout="vertical"
onFinish={onSubmit}
onFinish={values => onSubmit({ ...values, turnstileToken })}
onValuesChange={onValuesChange}
requiredMark={false}
>
Expand Down Expand Up @@ -110,13 +114,15 @@ export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props
</div>
)}

<TurnstileWidget onToken={setTurnstileToken} resetSignal={resetSignal} />

<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
disabled={loading}
disabled={loading || (turnstileEnabled && !turnstileToken)}
style={{
height: "3.5rem",
background: "linear-gradient(135deg, #2563eb 0%, #004ac6 100%)",
Expand Down
8 changes: 6 additions & 2 deletions client/src/features/auth/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ export const RegisterPage = () => {
const navigate = useNavigate();
const { mutate: signup, isPending: loading } = useSignup();
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [turnstileReset, setTurnstileReset] = useState(0);

const handleSubmit = (values: { fullName: string; email: string; password: string }) => {
const handleSubmit = (values: { fullName: string; email: string; password: string; turnstileToken: string | null }) => {
setErrorMsg(null);
signup(
{ username: values.fullName, email: values.email, password: values.password },
{ username: values.fullName, email: values.email, password: values.password, turnstileToken: values.turnstileToken },
{
onSuccess: data => {
setAuth(data.accessToken, data.user, data.notificationSettings);
navigate({ to: "/" });
},
onError: error => {
// Reset Turnstile — the single-use token was consumed by this attempt.
setTurnstileReset(n => n + 1);
const e = error as { response?: { data?: { message?: string } } };
setErrorMsg(e.response?.data?.message ?? "Registration failed. Please try again.");
},
Expand All @@ -45,6 +48,7 @@ export const RegisterPage = () => {
errorMsg={errorMsg}
onSubmit={handleSubmit}
onValuesChange={() => setErrorMsg(null)}
resetSignal={turnstileReset}
/>
</div>
</section>
Expand Down
13 changes: 9 additions & 4 deletions client/src/features/auth/register/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { useState } from "react";
import { Button, Form, Input } from "antd";
import { Link } from "@tanstack/react-router";
import { PasswordStrengthBars } from "./PasswordStrengthBars";
import { TurnstileWidget, turnstileEnabled } from "../shared/TurnstileWidget";

interface Props {
loading: boolean;
errorMsg: string | null;
onSubmit: (values: { fullName: string; email: string; password: string; confirmPassword: string }) => void;
onSubmit: (values: { fullName: string; email: string; password: string; confirmPassword: string; turnstileToken: string | null }) => void;
onValuesChange: () => void;
resetSignal?: number;
}

const GoogleSvg = () => (
Expand Down Expand Up @@ -35,9 +37,10 @@ const inputStyle = {
boxShadow: "0 8px 32px rgba(0,74,198,0.06)",
} as const;

export const RegisterForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props) => {
export const RegisterForm = ({ loading, errorMsg, onSubmit, onValuesChange, resetSignal }: Props) => {
const [form] = Form.useForm();
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);

return (
<>
Expand All @@ -55,7 +58,7 @@ export const RegisterForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Pr
<Form
form={form}
layout="vertical"
onFinish={onSubmit}
onFinish={values => onSubmit({ ...values, turnstileToken })}
onValuesChange={onValuesChange}
requiredMark={false}
>
Expand Down Expand Up @@ -144,13 +147,15 @@ export const RegisterForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Pr
</div>
)}

<TurnstileWidget onToken={setTurnstileToken} resetSignal={resetSignal} />

<Form.Item className="mt-4">
<Button
type="primary"
htmlType="submit"
block
loading={loading}
disabled={loading}
disabled={loading || (turnstileEnabled && !turnstileToken)}
style={{
height: "3.5rem",
background: "linear-gradient(135deg, #2563eb 0%, #004ac6 100%)",
Expand Down
38 changes: 38 additions & 0 deletions client/src/features/auth/shared/TurnstileWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useRef } from "react";
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";

const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;

export const turnstileEnabled = !!SITE_KEY;

interface Props {
onToken: (token: string | null) => void;
resetSignal?: number;
}

export const TurnstileWidget = ({ onToken, resetSignal }: Props) => {
const ref = useRef<TurnstileInstance | null>(null);

useEffect(() => {
if (resetSignal) {
ref.current?.reset();
onToken(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetSignal]);

if (!SITE_KEY) return null;

return (
<div className="mb-4 flex justify-center">
<Turnstile
ref={ref}
siteKey={SITE_KEY}
onSuccess={token => onToken(token)}
onError={() => onToken(null)}
onExpire={() => onToken(null)}
options={{ theme: "light" }}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { CONTACT_EMAIL } from '../data'

function ChevronIcon() {
return (
<svg viewBox="0 0 14 14" className="w-3.5 h-3.5 fill-none stroke-[#64748b]" strokeWidth={1.5} strokeLinecap="round">
<path d="M4 3l6 4-6 4" />
</svg>
)
}

export function ContactChannels() {
return (
<div className="bg-white rounded-xl shadow-[0_2px_12px_rgba(0,74,198,0.07)] border border-[rgba(195,198,215,0.25)]">
<div className="px-[22px] py-[18px] border-b border-[rgba(195,198,215,0.25)]">
<div className="font-extrabold text-[15px] text-[#131b2e]" style={{ fontFamily: 'Manrope, sans-serif' }}>Contact Channels</div>
</div>
<div className="p-4 flex flex-col gap-2.5">
<a href={`mailto:${CONTACT_EMAIL}`}
className="flex items-center gap-3 p-3 rounded-[10px] border border-[rgba(195,198,215,0.25)] bg-[#f7f9fb] no-underline text-inherit cursor-pointer transition-all duration-200 hover:bg-white hover:border-[rgba(195,198,215,0.5)]">
<div className="w-[38px] h-[38px] rounded-[9px] flex-shrink-0 flex items-center justify-center bg-[#dae2fd] text-[#004ac6]">
<svg viewBox="0 0 18 18" className="w-[17px] h-[17px] fill-none stroke-current" strokeWidth={1.3} strokeLinecap="round">
<rect x="1" y="4" width="16" height="11" rx="2" />
<path d="M1 7l8 5 8-5" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-[#131b2e]">Email Support</div>
<div className="text-[11.5px] text-[#64748b] mt-0.5">{CONTACT_EMAIL} · 24h response</div>
</div>
<ChevronIcon />
</a>
</div>
</div>
)
}
102 changes: 102 additions & 0 deletions client/src/features/dashboard/Help&Support/components/ContactForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useState } from 'react'
import { Input, Select, Button, message } from 'antd'
import { useAuthStore } from '../../../../store/auth.store'
import { TOPIC_OPTIONS } from '../data'
import { useSubmitSupport } from '../hooks'

const { TextArea } = Input

export function ContactForm() {
const user = useAuthStore(s => s.user)
const submit = useSubmitSupport()

const [topic, setTopic] = useState<string | undefined>(undefined)
const [body, setBody] = useState('')

const handleSend = () => {
if (!topic) {
message.warning('Please select a topic.')
return
}
if (body.trim().length < 10) {
message.warning('Please describe your issue in at least 10 characters.')
return
}

submit.mutate(
{ topic, message: body.trim() },
{
onSuccess: res => {
message.success(res.message || 'Your message has been sent.')
setTopic(undefined)
setBody('')
},
onError: (err: unknown) => {
const e = err as { response?: { data?: { message?: string | string[] } } }
const msg = e.response?.data?.message
message.error(Array.isArray(msg) ? msg.join(', ') : msg ?? 'Failed to send your message.')
},
},
)
}

return (
<div className="bg-white rounded-xl shadow-[0_2px_12px_rgba(0,74,198,0.07)] border border-[rgba(195,198,215,0.25)]">
<div className="px-[22px] py-[18px] border-b border-[rgba(195,198,215,0.25)]">
<div className="font-extrabold text-[15px] text-[#131b2e]" style={{ fontFamily: 'Manrope, sans-serif' }}>Contact Support</div>
<div className="text-xs text-[#64748b] mt-0.5">Can't find your answer? Send us a message and we'll respond within 24 hours.</div>
</div>
<div className="p-[22px] flex flex-col gap-4">
{/* Name + email are taken from your account */}
<div className="grid grid-cols-2 gap-3.5">
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-[#131b2e]">Full Name</label>
<Input value={user?.username ?? ''} size="large" disabled
className="rounded-[9px] bg-[#f7f9fb] border-[rgba(195,198,215,0.4)] text-[13.5px]" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-[#131b2e]">Email Address</label>
<Input value={user?.email ?? ''} size="large" disabled
className="rounded-[9px] bg-[#f7f9fb] border-[rgba(195,198,215,0.4)] text-[13.5px]" />
</div>
</div>

<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-[#131b2e]">Topic</label>
<Select size="large" placeholder="Select a topic"
value={topic}
onChange={setTopic}
className="w-full rounded-[9px] text-[13.5px]"
options={TOPIC_OPTIONS}
/>
</div>

<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-[#131b2e]">Message</label>
<TextArea rows={4} placeholder="Describe your issue in as much detail as possible. Include any error messages, scan IDs, or steps to reproduce the problem..."
value={body}
onChange={e => setBody(e.target.value)}
maxLength={5000}
className="rounded-[9px] bg-[#f7f9fb] border-[rgba(195,198,215,0.4)] text-[13.5px] resize-none" />
</div>

<Button type="primary" size="large" block
loading={submit.isPending}
onClick={handleSend}
className="h-11 rounded-[10px] font-bold text-[14px] border-none shadow-[0_6px_18px_rgba(0,74,198,0.2)] hover:opacity-90 hover:-translate-y-px transition-all"
style={{ background: 'linear-gradient(135deg, #2563eb, #004ac6)', fontFamily: 'Manrope, sans-serif' }}>
<svg viewBox="0 0 16 16" className="w-4 h-4 fill-none stroke-white inline mr-2 -mt-0.5" strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2L2 8l5 2 2 5 5-13z" />
</svg>
Send Message
</Button>
<div className="text-xs text-[#64748b] text-center flex items-center justify-center gap-1">
<svg viewBox="0 0 14 14" className="w-3 h-3 fill-none stroke-[#64748b]" strokeWidth={1.3} strokeLinecap="round">
<path d="M7 1a6 6 0 100 12A6 6 0 007 1zm0 3v3m0 2h.01" />
</svg>
We typically respond within 24 hours on business days.
</div>
</div>
</div>
)
}
Loading
Loading