diff --git a/.env.example b/.env.example index f9f98ce..d490cae 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,9 @@ # Set to "local" to proxy to http://host.docker.internal:8000 (no GCP auth needed). # Set to "remote" to fetch the production URL from GCP Secret Manager. BACKEND_TARGET=local + +# Google reCAPTCHA v2 site key for the public request page (/request). +# Leave unset to disable the captcha (the API still rate-limits by IP). +# For dev testing of the captcha flow, use Google's test key: +# 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI (always passes). +VITE_RECAPTCHA_SITE_KEY= diff --git a/src/App.tsx b/src/App.tsx index dce999f..1705221 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext" import { AuthProvider, useAuth } from "@/contexts/AuthContext" import AppShell from "@/components/layout/AppShell" import LoginPage from "@/components/pages/LoginPage" +import PublicRequestPage from "@/components/pages/publicRequest" import DashboardPage from "@/components/pages/DashboardPage" import LanguagesPage from "@/components/pages/LanguagesPage" import OrganizationsPage from "@/components/pages/OrganizationsPage" @@ -32,6 +33,7 @@ function App() { } /> + } /> } /> diff --git a/src/components/common/ReCaptcha.tsx b/src/components/common/ReCaptcha.tsx new file mode 100644 index 0000000..5a838ab --- /dev/null +++ b/src/components/common/ReCaptcha.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from "react" + +const SCRIPT_ID = "recaptcha-api-script" +const SCRIPT_SRC = "https://www.google.com/recaptcha/api.js?render=explicit" +const SITE_KEY = import.meta.env.VITE_RECAPTCHA_SITE_KEY ?? "" +const POLL_INTERVAL_MS = 150 +const MAX_POLL_ATTEMPTS = 100 + +export const RECAPTCHA_ENABLED = SITE_KEY.length > 0 + +interface GreCaptcha { + render: ( + container: HTMLElement, + params: { + sitekey: string + callback: (token: string) => void + "expired-callback": () => void + "error-callback": () => void + }, + ) => number + reset: (widgetId?: number) => void +} + +declare global { + interface Window { + grecaptcha?: GreCaptcha + } +} + +interface ReCaptchaProps { + onChange: (token: string | null) => void +} + +export function ReCaptcha({ onChange }: ReCaptchaProps) { + const containerRef = useRef(null) + const widgetIdRef = useRef(null) + const onChangeRef = useRef(onChange) + const [failed, setFailed] = useState(false) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + useEffect(() => { + if (!RECAPTCHA_ENABLED) return + + if (!document.getElementById(SCRIPT_ID)) { + const script = document.createElement("script") + script.id = SCRIPT_ID + script.src = SCRIPT_SRC + script.async = true + script.defer = true + script.onerror = () => setFailed(true) + document.head.appendChild(script) + } + + let attempts = 0 + const interval = window.setInterval(() => { + if (widgetIdRef.current !== null) { + window.clearInterval(interval) + return + } + if (window.grecaptcha?.render && containerRef.current) { + window.clearInterval(interval) + widgetIdRef.current = window.grecaptcha.render(containerRef.current, { + sitekey: SITE_KEY, + callback: (token) => onChangeRef.current(token), + "expired-callback": () => onChangeRef.current(null), + "error-callback": () => onChangeRef.current(null), + }) + return + } + attempts += 1 + if (attempts >= MAX_POLL_ATTEMPTS) { + window.clearInterval(interval) + setFailed(true) + } + }, POLL_INTERVAL_MS) + + return () => window.clearInterval(interval) + }, []) + + if (!RECAPTCHA_ENABLED) return null + + if (failed) { + return ( +

+ Failed to load the reCAPTCHA verification. Check your connection and reload the page. +

+ ) + } + + return
+} diff --git a/src/components/pages/LoginPage.tsx b/src/components/pages/LoginPage.tsx index f45c972..4068055 100644 --- a/src/components/pages/LoginPage.tsx +++ b/src/components/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { Navigate } from "react-router-dom" +import { Link, Navigate } from "react-router-dom" import { toast } from "sonner" import { useAuth } from "@/contexts/AuthContext" import { Input } from "@/components/ui/input" @@ -182,9 +182,17 @@ export default function LoginPage() { {/* Footer */}
-

- Need access? Contact your administrator. -

+
+

+ Need access? Contact your administrator. +

+ + Request a new project or language + +
diff --git a/src/components/pages/publicRequest/LanguageCombobox.tsx b/src/components/pages/publicRequest/LanguageCombobox.tsx new file mode 100644 index 0000000..4a551f2 --- /dev/null +++ b/src/components/pages/publicRequest/LanguageCombobox.tsx @@ -0,0 +1,106 @@ +import { useMemo, useState } from "react" +import { Check, ChevronsUpDown, Plus } from "lucide-react" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Input } from "@/components/ui/input" +import { cn } from "@/utils/cn" +import type { PublicLanguageOption } from "@/types" + +export const NEW_LANGUAGE = "__new__" + +interface LanguageComboboxProps { + languages: PublicLanguageOption[] + value: string + onChange: (value: string) => void +} + +export function LanguageCombobox({ languages, value, onChange }: LanguageComboboxProps) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return languages + return languages.filter( + (lang) => lang.name.toLowerCase().includes(q) || lang.code.toLowerCase().includes(q), + ) + }, [languages, query]) + + const selected = languages.find((lang) => lang.id === value) + const triggerLabel = + value === NEW_LANGUAGE + ? "+ Create a new language" + : selected + ? `${selected.name} (${selected.code})` + : "Select or search a language" + + function select(next: string) { + onChange(next) + setOpen(false) + setQuery("") + } + + return ( + + + + + + setQuery(e.target.value)} + placeholder="Search by name or code..." + className="h-9 mb-2" + autoFocus + /> +
+ + {filtered.map((lang) => ( + + ))} + {filtered.length === 0 && ( +

No language matches "{query}"

+ )} +
+
+
+ ) +} diff --git a/src/components/pages/publicRequest/LanguageRequestForm.tsx b/src/components/pages/publicRequest/LanguageRequestForm.tsx new file mode 100644 index 0000000..289de00 --- /dev/null +++ b/src/components/pages/publicRequest/LanguageRequestForm.tsx @@ -0,0 +1,130 @@ +import { useState } from "react" +import { toast } from "sonner" +import { publicAPI } from "@/services/api" +import type { PublicLanguageOption } from "@/types" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { InfoTooltip } from "@/components/common/InfoTooltip" +import { ReCaptcha, RECAPTCHA_ENABLED } from "@/components/common/ReCaptcha" +import { Loader2 } from "lucide-react" + +interface LanguageRequestFormProps { + requesterName: string + requesterEmail: string + requesterValid: boolean + languages: PublicLanguageOption[] + onSuccess: () => void +} + +export function LanguageRequestForm({ + requesterName, + requesterEmail, + requesterValid, + languages, + onSuccess, +}: LanguageRequestFormProps) { + const [name, setName] = useState("") + const [code, setCode] = useState("") + const [captchaToken, setCaptchaToken] = useState(null) + const [captchaEpoch, setCaptchaEpoch] = useState(0) + const [submitting, setSubmitting] = useState(false) + + const normalizedCode = code.trim().toLowerCase() + const normalizedName = name.trim().toLowerCase() + const codeExists = languages.some((lang) => lang.code.toLowerCase() === normalizedCode) + const nameExists = languages.some((lang) => lang.name.toLowerCase() === normalizedName) + const canSubmit = + requesterValid && + name.trim().length > 0 && + !nameExists && + /^[A-Za-z]{3}$/.test(code.trim()) && + !codeExists && + (!RECAPTCHA_ENABLED || !!captchaToken) && + !submitting + + function resetCaptcha() { + setCaptchaToken(null) + setCaptchaEpoch((epoch) => epoch + 1) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!canSubmit) return + setSubmitting(true) + try { + await publicAPI.requestLanguage({ + requester_name: requesterName.trim(), + requester_email: requesterEmail.trim(), + name: name.trim(), + code: normalizedCode, + recaptcha_token: captchaToken ?? undefined, + }) + onSuccess() + } catch (err: unknown) { + const response = (err as { response?: { status?: number; data?: { detail?: string } } }) + ?.response + const detail = typeof response?.data?.detail === "string" ? response.data.detail : null + if (response?.status === 409) { + toast.error(detail || "A language with this name or code already exists") + } else if (response?.status === 429) { + toast.error("Too many requests. Please wait a moment and try again.") + } else if (response?.status === 422) { + toast.error("Please check your email address and the form fields.") + } else if (response?.status === 400) { + toast.error(detail || "The request was rejected. Please try again.") + } else { + toast.error("Failed to submit the request") + } + resetCaptcha() + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + setName(e.target.value)} + /> + {nameExists && ( +

+ This language name is already registered in the system. +

+ )} +
+
+ + setCode(e.target.value)} + /> + {codeExists ? ( +

+ This language is already registered in the system. +

+ ) : ( +

Must be exactly 3 characters (ISO 639-3)

+ )} +
+ + + + ) +} diff --git a/src/components/pages/publicRequest/ProjectRequestForm.tsx b/src/components/pages/publicRequest/ProjectRequestForm.tsx new file mode 100644 index 0000000..c86b3fb --- /dev/null +++ b/src/components/pages/publicRequest/ProjectRequestForm.tsx @@ -0,0 +1,193 @@ +import { useState } from "react" +import { toast } from "sonner" +import { publicAPI } from "@/services/api" +import type { PublicLanguageOption } from "@/types" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { InfoTooltip } from "@/components/common/InfoTooltip" +import { ReCaptcha, RECAPTCHA_ENABLED } from "@/components/common/ReCaptcha" +import { LanguageCombobox, NEW_LANGUAGE } from "./LanguageCombobox" +import { Loader2 } from "lucide-react" + +interface ProjectRequestFormProps { + requesterName: string + requesterEmail: string + requesterValid: boolean + languages: PublicLanguageOption[] + languagesLoading: boolean + languagesError: boolean + onRetryLanguages: () => void + onSuccess: () => void +} + +export function ProjectRequestForm({ + requesterName, + requesterEmail, + requesterValid, + languages, + languagesLoading, + languagesError, + onRetryLanguages, + onSuccess, +}: ProjectRequestFormProps) { + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [languageId, setLanguageId] = useState("") + const [newLangName, setNewLangName] = useState("") + const [newLangCode, setNewLangCode] = useState("") + const [captchaToken, setCaptchaToken] = useState(null) + const [captchaEpoch, setCaptchaEpoch] = useState(0) + const [submitting, setSubmitting] = useState(false) + + const wantsNewLanguage = languageId === NEW_LANGUAGE + const normalizedNewName = newLangName.trim().toLowerCase() + const normalizedNewCode = newLangCode.trim().toLowerCase() + const newNameExists = languages.some((lang) => lang.name.toLowerCase() === normalizedNewName) + const newCodeExists = languages.some((lang) => lang.code.toLowerCase() === normalizedNewCode) + const newLangValid = + newLangName.trim().length > 0 && + /^[A-Za-z]{3}$/.test(newLangCode.trim()) && + !newNameExists && + !newCodeExists + const languageValid = wantsNewLanguage + ? newLangValid + : languageId.length > 0 && languageId !== NEW_LANGUAGE + const canSubmit = + requesterValid && + name.trim().length > 0 && + languageValid && + (!RECAPTCHA_ENABLED || !!captchaToken) && + !submitting + + function resetCaptcha() { + setCaptchaToken(null) + setCaptchaEpoch((epoch) => epoch + 1) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!canSubmit) return + setSubmitting(true) + try { + await publicAPI.requestProject({ + requester_name: requesterName.trim(), + requester_email: requesterEmail.trim(), + name: name.trim(), + description: description.trim() || undefined, + language_id: wantsNewLanguage ? undefined : languageId, + new_language_name: wantsNewLanguage ? newLangName.trim() : undefined, + new_language_code: wantsNewLanguage ? normalizedNewCode : undefined, + recaptcha_token: captchaToken ?? undefined, + }) + onSuccess() + } catch (err: unknown) { + const response = (err as { response?: { status?: number; data?: { detail?: string } } }) + ?.response + const detail = typeof response?.data?.detail === "string" ? response.data.detail : null + if (response?.status === 409) { + toast.error(detail || "This language already exists or was already requested") + } else if (response?.status === 429) { + toast.error("Too many requests. Please wait a moment and try again.") + } else if (response?.status === 422) { + toast.error("Please check your email address and the form fields.") + } else if (response?.status === 400) { + toast.error(detail || "The request was rejected. Please try again.") + } else { + toast.error("Failed to submit the request") + } + resetCaptcha() + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + setName(e.target.value)} + /> +
+
+ +