Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -32,6 +33,7 @@ function App() {
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/request" element={<PublicRequestPage />} />

<Route path="/" element={<Navigate to="/app/dashboard" replace />} />

Expand Down
94 changes: 94 additions & 0 deletions src/components/common/ReCaptcha.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const widgetIdRef = useRef<number | null>(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 (
<p className="text-xs text-red-600 dark:text-red-400">
Failed to load the reCAPTCHA verification. Check your connection and reload the page.
</p>
)
}

return <div ref={containerRef} className="min-h-[78px]" />
}
16 changes: 12 additions & 4 deletions src/components/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -182,9 +182,17 @@ export default function LoginPage() {

{/* Footer */}
<div className="mt-10 pt-6 border-t border-areia/15 flex items-center justify-between">
<p className="text-sm text-verde/40">
Need access? Contact your administrator.
</p>
<div className="space-y-1">
<p className="text-sm text-verde/40">
Need access? Contact your administrator.
</p>
<Link
to="/request"
className="text-sm font-medium text-telha hover:underline"
>
Request a new project or language
</Link>
</div>
<div className="hidden lg:flex items-center gap-2">
<div className="h-6 w-6 rounded-md bg-telha/8 flex items-center justify-center">
<img src="/assets/icon-dark.svg" alt="" className="h-3.5 w-3.5 opacity-35" />
Expand Down
106 changes: 106 additions & 0 deletions src/components/pages/publicRequest/LanguageCombobox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className="flex h-10 w-full items-center justify-between rounded-lg border border-areia bg-surface px-3 py-2 text-sm text-preto focus:ring-2 focus:ring-telha focus:border-telha"
>
<span className={cn("truncate", !selected && value !== NEW_LANGUAGE && "text-areia")}>
{triggerLabel}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="p-2"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or code..."
className="h-9 mb-2"
autoFocus
/>
<div className="max-h-56 overflow-y-auto space-y-0.5">
<button
type="button"
onClick={() => select(NEW_LANGUAGE)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-telha hover:bg-areia/10",
value === NEW_LANGUAGE && "bg-areia/10",
)}
>
<Plus className="h-4 w-4 shrink-0" />
Create a new language
</button>
{filtered.map((lang) => (
<button
key={lang.id}
type="button"
onClick={() => select(lang.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-preto hover:bg-areia/10"
>
<Check
className={cn(
"h-4 w-4 shrink-0 text-telha",
value === lang.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">
{lang.name} ({lang.code})
</span>
</button>
))}
{filtered.length === 0 && (
<p className="px-2 py-1.5 text-sm text-verde/50">No language matches "{query}"</p>
)}
</div>
</PopoverContent>
</Popover>
)
}
130 changes: 130 additions & 0 deletions src/components/pages/publicRequest/LanguageRequestForm.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="request-lang-name">Language name</Label>
<Input
id="request-lang-name"
placeholder="e.g. Arara"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{nameExists && (
<p className="text-xs text-red-600 dark:text-red-400">
This language name is already registered in the system.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="request-lang-code">
<span className="inline-flex items-center">
Code
<InfoTooltip content="Exactly 3 characters, ISO 639-3." />
</span>
</Label>
<Input
id="request-lang-code"
placeholder="e.g. ara"
maxLength={3}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
{codeExists ? (
<p className="text-xs text-red-600 dark:text-red-400">
This language is already registered in the system.
</p>
) : (
<p className="text-xs text-verde/50">Must be exactly 3 characters (ISO 639-3)</p>
)}
</div>
<ReCaptcha key={captchaEpoch} onChange={setCaptchaToken} />
<Button type="submit" className="w-full h-11 rounded-xl" disabled={!canSubmit}>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{submitting ? "Submitting..." : "Submit language request"}
</Button>
</form>
)
}
Loading
Loading