mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
Add OPEN_REGISTRATION gate, signup request form, favicon
This commit is contained in:
@@ -30,6 +30,17 @@ MONERO_WALLET_RPC_WALLET_DIR=/wallets
|
|||||||
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568
|
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568
|
||||||
MONERO_DAEMON_URL=http://192.168.88.235:34568
|
MONERO_DAEMON_URL=http://192.168.88.235:34568
|
||||||
|
|
||||||
|
# Registration (set to false to require manual merchant onboarding)
|
||||||
|
OPEN_REGISTRATION=true
|
||||||
|
|
||||||
|
# Signup form (optional, only needed when OPEN_REGISTRATION=false)
|
||||||
|
# Cloudflare Turnstile - bot protection for signup form
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
# Resend - email delivery for signup requests
|
||||||
|
RESEND_API_KEY=
|
||||||
|
CONTACT_EMAIL=sales@such.software
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
API_BASE_URL=http://api:8000
|
API_BASE_URL=http://api:8000
|
||||||
SITE_URL=https://wowcheckout.such.software
|
SITE_URL=https://wowcheckout.such.software
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30"))
|
|||||||
DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25"))
|
DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25"))
|
||||||
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken")
|
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken")
|
||||||
DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False)
|
DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False)
|
||||||
|
OPEN_REGISTRATION = _get_bool_env("OPEN_REGISTRATION", True)
|
||||||
|
|
||||||
MONERO_WALLET_RPC_URLS = [
|
MONERO_WALLET_RPC_URLS = [
|
||||||
url.strip()
|
url.strip()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .config import (
|
|||||||
FOUNDER_PAYMENT_ADDRESS,
|
FOUNDER_PAYMENT_ADDRESS,
|
||||||
FOUNDER_VIEW_KEY,
|
FOUNDER_VIEW_KEY,
|
||||||
INVOICE_DEFAULT_EXPIRY_HOURS,
|
INVOICE_DEFAULT_EXPIRY_HOURS,
|
||||||
|
OPEN_REGISTRATION,
|
||||||
QR_STORAGE_DIR,
|
QR_STORAGE_DIR,
|
||||||
)
|
)
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
@@ -724,6 +725,11 @@ def login_user(payload: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
_validate_payment_address_and_view_key(payment_address, view_key)
|
_validate_payment_address_and_view_key(payment_address, view_key)
|
||||||
user = db.query(User).filter(User.payment_address == payment_address).first()
|
user = db.query(User).filter(User.payment_address == payment_address).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
|
if not OPEN_REGISTRATION:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="registration_closed",
|
||||||
|
)
|
||||||
api_key = generate_api_key()
|
api_key = generate_api_key()
|
||||||
webhook_secret = generate_webhook_secret()
|
webhook_secret = generate_webhook_secret()
|
||||||
user = User(
|
user = User(
|
||||||
@@ -770,6 +776,11 @@ def validate_login(payload: LoginRequest) -> Response:
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/core/auth/registration-status")
|
||||||
|
def registration_status():
|
||||||
|
return {"open": OPEN_REGISTRATION}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/core/api-credentials/reset",
|
"/api/core/api-credentials/reset",
|
||||||
response_model=ApiCredentialsResetResponse,
|
response_model=ApiCredentialsResetResponse,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS}
|
FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS}
|
||||||
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
|
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
|
||||||
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
||||||
|
OPEN_REGISTRATION: ${OPEN_REGISTRATION:-true}
|
||||||
QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr}
|
QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr}
|
||||||
MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083}
|
MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083}
|
||||||
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
|
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
|
||||||
@@ -182,10 +183,18 @@ services:
|
|||||||
ui:
|
ui:
|
||||||
build:
|
build:
|
||||||
context: ./ui
|
context: ./ui
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
|
||||||
|
NEXT_PUBLIC_SITE_NAME: ${SITE_NAME:-WOW Checkout}
|
||||||
environment:
|
environment:
|
||||||
API_BASE_URL: ${API_BASE_URL:-http://nginx/api}
|
API_BASE_URL: ${API_BASE_URL:-http://nginx/api}
|
||||||
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost}
|
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost}
|
||||||
NEXT_PUBLIC_DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
NEXT_PUBLIC_DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
|
||||||
|
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
CONTACT_EMAIL: ${CONTACT_EMAIL:-sales@such.software}
|
||||||
|
NEXT_PUBLIC_SITE_NAME: ${SITE_NAME:-WOW Checkout}
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
|
ARG NEXT_PUBLIC_SITE_NAME
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export async function loginAction(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.json().catch(() => null);
|
const detail = await response.json().catch(() => null);
|
||||||
|
if (detail?.detail === "registration_closed") {
|
||||||
|
redirect("/signup");
|
||||||
|
}
|
||||||
const message = detail?.detail ?? "Unable to sign in.";
|
const message = detail?.detail ?? "Unable to sign in.";
|
||||||
return { error: message };
|
return { error: message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { verifyTurnstile } from "../../../lib/turnstile";
|
||||||
|
|
||||||
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
|
const CONTACT_EMAIL = process.env.CONTACT_EMAIL || "sales@such.software";
|
||||||
|
const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME || "XMR Checkout";
|
||||||
|
|
||||||
|
export type SignupState = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function submitSignupRequest(
|
||||||
|
_prevState: SignupState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<SignupState> {
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
const storeUrl = String(formData.get("store_url") ?? "").trim();
|
||||||
|
const message = String(formData.get("message") ?? "").trim();
|
||||||
|
const token = String(formData.get("token") ?? "").trim();
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (name.length < 2) errors.name = "Name is too short.";
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
||||||
|
errors.email = "Invalid email address.";
|
||||||
|
if (message.length < 10) errors.message = "Please tell us more.";
|
||||||
|
if (!token) errors.token = "Please complete the security check.";
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return { success: false, message: "", errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHuman = await verifyTurnstile(token);
|
||||||
|
if (!isHuman) {
|
||||||
|
return { success: false, message: "Security check failed. Please refresh." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RESEND_API_KEY) {
|
||||||
|
console.warn("RESEND_API_KEY not set, logging signup request instead");
|
||||||
|
console.log("Signup request:", { name, email, storeUrl, message });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Request received. We will review your application and get back to you.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Resend } = await import("resend");
|
||||||
|
const resend = new Resend(RESEND_API_KEY);
|
||||||
|
|
||||||
|
const subject = `[${SITE_NAME}] Merchant access request from ${name}`;
|
||||||
|
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: `${SITE_NAME} <noreply@such.software>`,
|
||||||
|
to: CONTACT_EMAIL,
|
||||||
|
replyTo: email,
|
||||||
|
subject,
|
||||||
|
text: [
|
||||||
|
`Name: ${name}`,
|
||||||
|
`Email: ${email}`,
|
||||||
|
`Store URL: ${storeUrl || "(not provided)"}`,
|
||||||
|
"",
|
||||||
|
"Message:",
|
||||||
|
message,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<h2>Merchant Access Request</h2>
|
||||||
|
<p><strong>Name:</strong> ${name}</p>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
|
||||||
|
<p><strong>Store URL:</strong> ${storeUrl ? `<a href="${storeUrl}">${storeUrl}</a>` : "<em>not provided</em>"}</p>
|
||||||
|
<h3>Message:</h3>
|
||||||
|
<p>${message.replace(/\n/g, "<br>")}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Resend error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to send request. Please try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Request received. We will review your application and get back to you.",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Email error:", e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "System error. Please try again or contact us directly.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import SignupForm from "./signup-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Request Access",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-inner" style={{ maxWidth: 560 }}>
|
||||||
|
<p className="eyebrow">Merchant access</p>
|
||||||
|
<h1 className="section-title">Request access</h1>
|
||||||
|
<p className="mt-4 text-ink-soft">
|
||||||
|
Tell us about your store and we will get you set up. Once approved
|
||||||
|
you will receive your credentials by email.
|
||||||
|
</p>
|
||||||
|
<SignupForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import TurnstileWidget from "../../../components/turnstile-widget";
|
||||||
|
import { submitSignupRequest, type SignupState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: SignupState = { success: false, message: "" };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:bg-ink/60 disabled:text-cream/70 disabled:shadow-none disabled:hover:translate-y-0"
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? "Sending..." : "Request Access"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignupForm() {
|
||||||
|
const [state, formAction] = useActionState(submitSignupRequest, initialState);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success && formRef.current) {
|
||||||
|
formRef.current.reset();
|
||||||
|
setToken("");
|
||||||
|
}
|
||||||
|
}, [state.success]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} className="mt-6 grid gap-5" action={formAction}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="name"
|
||||||
|
>
|
||||||
|
Your name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{state.errors?.name ? (
|
||||||
|
<p className="text-xs text-red-600">{state.errors.name}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="email"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{state.errors?.email ? (
|
||||||
|
<p className="text-xs text-red-600">{state.errors.email}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="store_url"
|
||||||
|
>
|
||||||
|
Store URL <span className="normal-case tracking-normal text-ink-soft/60">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="store_url"
|
||||||
|
name="store_url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="message"
|
||||||
|
>
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[120px] w-full resize-none rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
placeholder="Tell us about your store and how you plan to accept payments..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{state.errors?.message ? (
|
||||||
|
<p className="text-xs text-red-600">{state.errors.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-t border-stroke pt-4">
|
||||||
|
<TurnstileWidget onVerify={setToken} />
|
||||||
|
<SubmitButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.message ? (
|
||||||
|
<p
|
||||||
|
className={`rounded-lg border px-3 py-2 text-sm font-semibold ${
|
||||||
|
state.success
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-red-200 bg-red-50 text-red-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import LoginForm from "../app/(app)/login/login-form";
|
import LoginForm from "../app/(app)/login/login-form";
|
||||||
@@ -116,6 +117,16 @@ export default function LoginModal() {
|
|||||||
<p className="mt-4 text-sm font-semibold text-sage">
|
<p className="mt-4 text-sm font-semibold text-sage">
|
||||||
We never require more than view-only access to keep your funds safe.
|
We never require more than view-only access to keep your funds safe.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="font-semibold text-clay underline underline-offset-4"
|
||||||
|
onClick={closeModal}
|
||||||
|
>
|
||||||
|
Request access
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||||
|
|
||||||
|
export default function TurnstileWidget({
|
||||||
|
onVerify,
|
||||||
|
}: {
|
||||||
|
onVerify: (token: string) => void;
|
||||||
|
}) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!SITE_KEY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="h-[65px] w-[300px]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-ink-soft">Security check unavailable</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Turnstile
|
||||||
|
siteKey={SITE_KEY}
|
||||||
|
onSuccess={onVerify}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
onExpire={() => onVerify("")}
|
||||||
|
options={{ theme: "light" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export async function verifyTurnstile(token: string) {
|
||||||
|
const secretKey = process.env.TURNSTILE_SECRET_KEY;
|
||||||
|
if (!secretKey) {
|
||||||
|
console.warn("No TURNSTILE_SECRET_KEY set, skipping verification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("secret", secretKey);
|
||||||
|
formData.append("response", token);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
{ method: "POST", body: formData }
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.success;
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"resend": "^4.1.2",
|
||||||
"tsconfig-paths": "^4.2.0"
|
"tsconfig-paths": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user