diff --git a/.env.example b/.env.example
index 1734305..efef8c1 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,10 @@ BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes
# Separate trusted browser origins with commas for local development or proxies.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
+# Cloudflare Turnstile administrator login protection
+TURNSTILE_SITE_KEY=replace-with-turnstile-site-key
+TURNSTILE_SECRET_KEY=replace-with-turnstile-secret-key
+
# Redis remote cache, event transport, and outbox worker
REDIS_URL=rediss://default:replace-me@redis.example.internal:6379
REDIS_CACHE_PREFIX=buzz:next-cache
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
index 05bc6ac..943b8be 100644
--- a/app/admin/login/page.tsx
+++ b/app/admin/login/page.tsx
@@ -9,11 +9,16 @@ export default async function AdminLoginPage() {
await connection();
if (await getAdminSession()) redirect("/admin");
+ const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY;
+ if (!turnstileSiteKey) {
+ throw new Error("TURNSTILE_SITE_KEY is required for administrator login.");
+ }
+
return (
-
+
);
diff --git a/components/admin/login-card.tsx b/components/admin/login-card.tsx
index 2bdb6f6..b26f7d0 100644
--- a/components/admin/login-card.tsx
+++ b/components/admin/login-card.tsx
@@ -3,7 +3,8 @@
import { CircleAlertIcon, LogInIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { type FormEvent, useState } from "react";
+import Script from "next/script";
+import { type FormEvent, useCallback, useRef, useState } from "react";
import {
Alert,
@@ -24,13 +25,73 @@ import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { authClient } from "@/lib/auth/client";
-export function LoginCard() {
+interface TurnstileApi {
+ render(
+ container: HTMLElement,
+ options: {
+ sitekey: string;
+ callback: (token: string) => void;
+ "expired-callback": () => void;
+ "error-callback": () => void;
+ },
+ ): string;
+ reset(widgetId: string): void;
+}
+
+declare global {
+ interface Window {
+ turnstile?: TurnstileApi;
+ }
+}
+
+interface LoginCardProps {
+ turnstileSiteKey: string;
+}
+
+export function LoginCard({ turnstileSiteKey }: LoginCardProps) {
const router = useRouter();
+ const turnstileContainerRef = useRef(null);
+ const turnstileWidgetIdRef = useRef(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [turnstileToken, setTurnstileToken] = useState(null);
+
+ const renderTurnstile = useCallback(() => {
+ if (
+ !window.turnstile ||
+ !turnstileContainerRef.current ||
+ turnstileWidgetIdRef.current
+ ) {
+ return;
+ }
+
+ turnstileWidgetIdRef.current = window.turnstile.render(
+ turnstileContainerRef.current,
+ {
+ sitekey: turnstileSiteKey,
+ callback: (token) => {
+ setTurnstileToken(token);
+ setError(null);
+ },
+ "expired-callback": () => setTurnstileToken(null),
+ "error-callback": () => {
+ setTurnstileToken(null);
+ setError("ยืนยันตัวตนไม่สำเร็จ โปรดลองอีกครั้ง");
+ },
+ },
+ );
+ }, [turnstileSiteKey]);
+
+ function resetTurnstile() {
+ setTurnstileToken(null);
+ if (window.turnstile && turnstileWidgetIdRef.current) {
+ window.turnstile.reset(turnstileWidgetIdRef.current);
+ }
+ }
async function signIn(event: FormEvent) {
event.preventDefault();
+ if (!turnstileToken) return;
setLoading(true);
setError(null);
@@ -43,6 +104,9 @@ export function LoginCard() {
email,
password,
rememberMe: true,
+ fetchOptions: {
+ headers: { "x-captcha-response": turnstileToken },
+ },
});
if (result.error) {
setError(
@@ -57,6 +121,7 @@ export function LoginCard() {
} catch {
setError("เชื่อมต่อระบบเข้าสู่ระบบไม่ได้ โปรดลองอีกครั้ง");
} finally {
+ resetTurnstile();
setLoading(false);
}
}
@@ -96,6 +161,19 @@ export function LoginCard() {
required
/>
+
+
+