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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ GOOGLE_GENERATIVE_AI_API_KEY=
# ── Zapier (optional — enables Automations tab in Settings) ───────────
# Get your Client ID from https://developer.zapier.com/ → Embed settings
VITE_ZAPIER_CLIENT_ID=

# ── Bot Protection (optional — Cloudflare Turnstile) ──────────────────
# Get your Site Key from https://dash.cloudflare.com/ → Turnstile
# Also add the Secret Key in Supabase Dashboard → Auth → Bot Protection
VITE_TURNSTILE_SITE_KEY=
15 changes: 13 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@marsidev/react-turnstile": "^1.5.0",
"@supabase/supabase-js": "^2.45.0",
"@tanstack/react-query": "^5.59.0",
"@xyflow/react": "^12.10.2",
Expand Down
43 changes: 38 additions & 5 deletions src/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

import { useState, type FormEvent } from 'react'
import { useState, useRef, type FormEvent } from 'react'
import type { AuthError, Provider } from '@supabase/supabase-js'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'

const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined

interface LoginFormProps {
onSignIn: (email: string, password: string) => Promise<{ error: AuthError | null }>
onSignIn: (email: string, password: string, captchaToken?: string) => Promise<{ error: AuthError | null }>
onOAuth: (provider: Provider) => Promise<{ error: AuthError | null }>
onToggle: () => void
onForgotPassword: () => void
Expand All @@ -16,15 +19,25 @@ export function LoginForm({ onSignIn, onOAuth, onToggle, onForgotPassword }: Log
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance | null>(null)

async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)

const { error: authError } = await onSignIn(email, password)
if (TURNSTILE_SITE_KEY && !captchaToken) {
setError('Please complete the verification')
return
}

setLoading(true)
const { error: authError } = await onSignIn(email, password, captchaToken ?? undefined)
if (authError) {
setError(authError.message)
// Reset Turnstile so user can retry
turnstileRef.current?.reset()
setCaptchaToken(null)
}
setLoading(false)
}
Expand Down Expand Up @@ -84,9 +97,29 @@ export function LoginForm({ onSignIn, onOAuth, onToggle, onForgotPassword }: Log
/>
</div>

{/* Cloudflare Turnstile — invisible bot protection */}
{TURNSTILE_SITE_KEY && (
<div className="flex justify-center">
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => setCaptchaToken(token)}
onError={() => {
setCaptchaToken(null)
setError('Verification failed. Please try again.')
}}
onExpire={() => setCaptchaToken(null)}
options={{
theme: 'dark',
size: 'flexible',
}}
/>
</div>
)}

<button
type="submit"
disabled={loading}
disabled={loading || (!!TURNSTILE_SITE_KEY && !captchaToken)}
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
Expand Down
41 changes: 38 additions & 3 deletions src/components/auth/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

import { useState, type FormEvent } from 'react'
import { useState, useRef, type FormEvent } from 'react'
import type { AuthError } from '@supabase/supabase-js'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'

const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined

interface SignupFormProps {
onSignUp: (email: string, password: string, fullName?: string) => Promise<{ error: AuthError | null }>
onSignUp: (email: string, password: string, fullName?: string, captchaToken?: string) => Promise<{ error: AuthError | null }>
onToggle: () => void
}

Expand All @@ -17,6 +20,8 @@ export function SignupForm({ onSignUp, onToggle }: SignupFormProps) {
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance | null>(null)

async function handleSubmit(e: FormEvent) {
e.preventDefault()
Expand All @@ -32,14 +37,24 @@ export function SignupForm({ onSignUp, onToggle }: SignupFormProps) {
return
}

// If Turnstile is enabled, require a token
if (TURNSTILE_SITE_KEY && !captchaToken) {
setError('Please complete the verification')
return
}

setLoading(true)
const { error: authError } = await onSignUp(
email,
password,
fullName.trim() || undefined,
captchaToken ?? undefined,
)
if (authError) {
setError(authError.message)
// Reset Turnstile so user can retry
turnstileRef.current?.reset()
setCaptchaToken(null)
} else {
setSuccess(true)
}
Expand Down Expand Up @@ -137,9 +152,29 @@ export function SignupForm({ onSignUp, onToggle }: SignupFormProps) {
/>
</div>

{/* Cloudflare Turnstile — invisible bot protection */}
{TURNSTILE_SITE_KEY && (
<div className="flex justify-center">
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => setCaptchaToken(token)}
onError={() => {
setCaptchaToken(null)
setError('Verification failed. Please try again.')
}}
onExpire={() => setCaptchaToken(null)}
options={{
theme: 'dark',
size: 'flexible',
}}
/>
</div>
)}

<button
type="submit"
disabled={loading}
disabled={loading || (!!TURNSTILE_SITE_KEY && !captchaToken)}
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create Account'}
Expand Down
15 changes: 10 additions & 5 deletions src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ interface AuthState {
}

interface UseAuthReturn extends AuthState {
signIn: (email: string, password: string) => Promise<{ error: AuthError | null }>
signUp: (email: string, password: string, fullName?: string) => Promise<{ error: AuthError | null }>
signIn: (email: string, password: string, captchaToken?: string) => Promise<{ error: AuthError | null }>
signUp: (email: string, password: string, fullName?: string, captchaToken?: string) => Promise<{ error: AuthError | null }>
signOut: () => Promise<void>
signInWithOAuth: (provider: Provider) => Promise<{ error: AuthError | null }>
resetPassword: (email: string) => Promise<{ error: AuthError | null }>
Expand Down Expand Up @@ -53,16 +53,21 @@ export function useAuth(): UseAuthReturn {
}
}, [])

const signIn = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password })
const signIn = useCallback(async (email: string, password: string, captchaToken?: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
options: captchaToken ? { captchaToken } : undefined,
})
return { error }
}, [])

const signUp = useCallback(async (email: string, password: string, fullName?: string) => {
const signUp = useCallback(async (email: string, password: string, fullName?: string, captchaToken?: string) => {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
captchaToken,
data: {
...(fullName ? { full_name: fullName } : {}),
is_beta: true,
Expand Down
Loading