From 06071d139e6b916eff40bb1a0b674a92f4abaf76 Mon Sep 17 00:00:00 2001 From: Levi Gomes Date: Fri, 10 Jul 2026 14:03:56 -0300 Subject: [PATCH 1/7] feat(api): add public request types and publicAPI namespace Types mirror the new unauthenticated backend endpoints under /api/public: slim language options for the combobox and creation request payloads carrying requester name/email and the reCAPTCHA token. publicAPI reuses the existing axios instance since public endpoints never return 401. --- src/services/api.ts | 12 ++++++++++++ src/types/index.ts | 7 +++++++ src/types/publicRequest.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 src/types/publicRequest.ts diff --git a/src/services/api.ts b/src/services/api.ts index d0f3a8c..c650969 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -43,6 +43,10 @@ import type { PhaseUpdate, PhaseDependencyResponse, ProjectPhaseResponse, + PublicLanguageOption, + PublicLanguageRequestCreate, + PublicProjectRequestCreate, + PublicRequestResponse, } from "@/types" const api = axios.create({ @@ -304,4 +308,12 @@ export const uploadsAPI = { }, } +export const publicAPI = { + listLanguages: () => api.get("/public/languages"), + requestLanguage: (data: PublicLanguageRequestCreate) => + api.post("/public/requests/language", data), + requestProject: (data: PublicProjectRequestCreate) => + api.post("/public/requests/project", data), +} + export default api diff --git a/src/types/index.ts b/src/types/index.ts index e343aa0..fffb2b5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,3 +68,10 @@ export type { PhaseDependencyResponse, ProjectPhaseResponse, } from "./phase" + +export type { + PublicLanguageOption, + PublicLanguageRequestCreate, + PublicProjectRequestCreate, + PublicRequestResponse, +} from "./publicRequest" diff --git a/src/types/publicRequest.ts b/src/types/publicRequest.ts new file mode 100644 index 0000000..a005a88 --- /dev/null +++ b/src/types/publicRequest.ts @@ -0,0 +1,35 @@ +export interface PublicLanguageOption { + id: string + name: string + code: string +} + +export interface PublicLanguageRequestCreate { + requester_name: string + requester_email: string + name: string + code: string + recaptcha_token: string +} + +export interface PublicProjectRequestCreate { + requester_name: string + requester_email: string + name: string + description?: string | null + language_id: string + recaptcha_token: string +} + +export interface PublicRequestResponse { + id: string + kind: string + status: string + requester_name: string + requester_email: string + name: string + code: string | null + description: string | null + language_id: string | null + requested_at: string +} From 21b45bf4329d02cdf019236bca8d942303c8b819 Mon Sep 17 00:00:00 2001 From: Levi Gomes Date: Fri, 10 Jul 2026 14:03:56 -0300 Subject: [PATCH 2/7] feat(request): add public request page with reCAPTCHA New /request route outside the authenticated shell. Visitors provide name and email and submit either a language request (blocked when the 3-char code already exists in the system) or a project request whose language combobox lists languages already registered, via the public endpoints. ReCaptcha renders the v2 checkbox on demand, injecting the Google script only on this page; the site key comes from VITE_RECAPTCHA_SITE_KEY with Google's test key as dev fallback. --- .env.example | 4 + src/App.tsx | 2 + src/components/common/ReCaptcha.tsx | 69 +++++++ .../publicRequest/LanguageRequestForm.tsx | 116 +++++++++++ .../publicRequest/ProjectRequestForm.tsx | 139 +++++++++++++ src/components/pages/publicRequest/index.tsx | 186 ++++++++++++++++++ src/vite-env.d.ts | 8 + 7 files changed, 524 insertions(+) create mode 100644 src/components/common/ReCaptcha.tsx create mode 100644 src/components/pages/publicRequest/LanguageRequestForm.tsx create mode 100644 src/components/pages/publicRequest/ProjectRequestForm.tsx create mode 100644 src/components/pages/publicRequest/index.tsx diff --git a/.env.example b/.env.example index f9f98ce..ddf16c0 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,7 @@ # 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 in dev to fall back to Google's official test key (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..0f84b3a --- /dev/null +++ b/src/components/common/ReCaptcha.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from "react" + +const SCRIPT_ID = "recaptcha-api-script" +const SCRIPT_SRC = "https://www.google.com/recaptcha/api.js?render=explicit" +const TEST_SITE_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" +const SITE_KEY = import.meta.env.VITE_RECAPTCHA_SITE_KEY || TEST_SITE_KEY + +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) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + useEffect(() => { + if (!document.getElementById(SCRIPT_ID)) { + const script = document.createElement("script") + script.id = SCRIPT_ID + script.src = SCRIPT_SRC + script.async = true + script.defer = true + document.head.appendChild(script) + } + + const interval = window.setInterval(() => { + if (widgetIdRef.current !== null) { + window.clearInterval(interval) + return + } + if (!window.grecaptcha?.render || !containerRef.current) return + 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), + }) + }, 150) + + return () => window.clearInterval(interval) + }, []) + + return
+} diff --git a/src/components/pages/publicRequest/LanguageRequestForm.tsx b/src/components/pages/publicRequest/LanguageRequestForm.tsx new file mode 100644 index 0000000..6f7f8a5 --- /dev/null +++ b/src/components/pages/publicRequest/LanguageRequestForm.tsx @@ -0,0 +1,116 @@ +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 } 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 codeExists = languages.some((lang) => lang.code === normalizedCode) + const canSubmit = + requesterValid && + name.trim().length > 0 && + normalizedCode.length === 3 && + !codeExists && + !!captchaToken && + !submitting + + function resetCaptcha() { + setCaptchaToken(null) + setCaptchaEpoch((epoch) => epoch + 1) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!canSubmit || !captchaToken) return + setSubmitting(true) + try { + await publicAPI.requestLanguage({ + requester_name: requesterName.trim(), + requester_email: requesterEmail.trim(), + name: name.trim(), + code: normalizedCode, + recaptcha_token: captchaToken, + }) + onSuccess() + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 409) { + toast.error("A language with this code already exists or was already requested") + } else if (status === 400) { + toast.error("reCAPTCHA verification failed. Please try again.") + } else { + toast.error("Failed to submit the request") + } + resetCaptcha() + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + setName(e.target.value)} + /> +
+
+ + 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..3fc19fc --- /dev/null +++ b/src/components/pages/publicRequest/ProjectRequestForm.tsx @@ -0,0 +1,139 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { InfoTooltip } from "@/components/common/InfoTooltip" +import { ReCaptcha } from "@/components/common/ReCaptcha" +import { Loader2 } from "lucide-react" + +interface ProjectRequestFormProps { + requesterName: string + requesterEmail: string + requesterValid: boolean + languages: PublicLanguageOption[] + languagesLoading: boolean + onSuccess: () => void +} + +export function ProjectRequestForm({ + requesterName, + requesterEmail, + requesterValid, + languages, + languagesLoading, + onSuccess, +}: ProjectRequestFormProps) { + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [languageId, setLanguageId] = useState("") + const [captchaToken, setCaptchaToken] = useState(null) + const [captchaEpoch, setCaptchaEpoch] = useState(0) + const [submitting, setSubmitting] = useState(false) + + const canSubmit = + requesterValid && + name.trim().length > 0 && + languageId.length > 0 && + !!captchaToken && + !submitting + + function resetCaptcha() { + setCaptchaToken(null) + setCaptchaEpoch((epoch) => epoch + 1) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!canSubmit || !captchaToken) return + setSubmitting(true) + try { + await publicAPI.requestProject({ + requester_name: requesterName.trim(), + requester_email: requesterEmail.trim(), + name: name.trim(), + description: description.trim() || undefined, + language_id: languageId, + recaptcha_token: captchaToken, + }) + onSuccess() + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 400) { + toast.error("reCAPTCHA verification failed. Please try again.") + } else { + toast.error("Failed to submit the request") + } + resetCaptcha() + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + setName(e.target.value)} + /> +
+
+ +