From 341d83196b5cecc01e5c89a2305f269a99deddbf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 9 Mar 2026 13:02:30 -0400 Subject: [PATCH] Add OPEN_REGISTRATION gate, signup request form, favicon --- .env.example | 11 ++ api/app/config.py | 1 + api/app/routes.py | 11 ++ docker-compose.yml | 9 ++ ui/Dockerfile | 2 + ui/app/(app)/login/actions.ts | 3 + ui/app/(marketing)/signup/actions.ts | 100 +++++++++++++++++ ui/app/(marketing)/signup/page.tsx | 22 ++++ ui/app/(marketing)/signup/signup-form.tsx | 131 ++++++++++++++++++++++ ui/app/icon.png | Bin 0 -> 32776 bytes ui/components/login-modal.tsx | 11 ++ ui/components/turnstile-widget.tsx | 43 +++++++ ui/lib/turnstile.ts | 19 ++++ ui/package.json | 2 + 14 files changed, 365 insertions(+) create mode 100644 ui/app/(marketing)/signup/actions.ts create mode 100644 ui/app/(marketing)/signup/page.tsx create mode 100644 ui/app/(marketing)/signup/signup-form.tsx create mode 100644 ui/app/icon.png create mode 100644 ui/components/turnstile-widget.tsx create mode 100644 ui/lib/turnstile.ts diff --git a/.env.example b/.env.example index e8c3b46..397374d 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,17 @@ MONERO_WALLET_RPC_WALLET_DIR=/wallets # - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod: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 API_BASE_URL=http://api:8000 SITE_URL=https://wowcheckout.such.software diff --git a/api/app/config.py b/api/app/config.py index b64812f..4930cc7 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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")) RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken") DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False) +OPEN_REGISTRATION = _get_bool_env("OPEN_REGISTRATION", True) MONERO_WALLET_RPC_URLS = [ url.strip() diff --git a/api/app/routes.py b/api/app/routes.py index 8bfdbe4..a4761db 100644 --- a/api/app/routes.py +++ b/api/app/routes.py @@ -22,6 +22,7 @@ from .config import ( FOUNDER_PAYMENT_ADDRESS, FOUNDER_VIEW_KEY, INVOICE_DEFAULT_EXPIRY_HOURS, + OPEN_REGISTRATION, QR_STORAGE_DIR, ) 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) user = db.query(User).filter(User.payment_address == payment_address).first() if user is None: + if not OPEN_REGISTRATION: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="registration_closed", + ) api_key = generate_api_key() webhook_secret = generate_webhook_secret() user = User( @@ -770,6 +776,11 @@ def validate_login(payload: LoginRequest) -> Response: 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( "/api/core/api-credentials/reset", response_model=ApiCredentialsResetResponse, diff --git a/docker-compose.yml b/docker-compose.yml index 73e0a9d..0372539 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS} FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY} DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false} + OPEN_REGISTRATION: ${OPEN_REGISTRATION:-true} 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_USER: ${MONERO_WALLET_RPC_USER} @@ -182,10 +183,18 @@ services: ui: build: context: ./ui + args: + NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} + NEXT_PUBLIC_SITE_NAME: ${SITE_NAME:-WOW Checkout} environment: API_BASE_URL: ${API_BASE_URL:-http://nginx/api} NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost} 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: api: condition: service_started diff --git a/ui/Dockerfile b/ui/Dockerfile index 089bb08..c38b60f 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -8,6 +8,8 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 +ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY +ARG NEXT_PUBLIC_SITE_NAME RUN npm run build FROM node:20-alpine AS runner diff --git a/ui/app/(app)/login/actions.ts b/ui/app/(app)/login/actions.ts index 7c049d3..979e1ef 100644 --- a/ui/app/(app)/login/actions.ts +++ b/ui/app/(app)/login/actions.ts @@ -29,6 +29,9 @@ export async function loginAction( if (!response.ok) { const detail = await response.json().catch(() => null); + if (detail?.detail === "registration_closed") { + redirect("/signup"); + } const message = detail?.detail ?? "Unable to sign in."; return { error: message }; } diff --git a/ui/app/(marketing)/signup/actions.ts b/ui/app/(marketing)/signup/actions.ts new file mode 100644 index 0000000..3096345 --- /dev/null +++ b/ui/app/(marketing)/signup/actions.ts @@ -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; +}; + +export async function submitSignupRequest( + _prevState: SignupState, + formData: FormData +): Promise { + 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 = {}; + 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} `, + to: CONTACT_EMAIL, + replyTo: email, + subject, + text: [ + `Name: ${name}`, + `Email: ${email}`, + `Store URL: ${storeUrl || "(not provided)"}`, + "", + "Message:", + message, + ].join("\n"), + html: ` +

Merchant Access Request

+

Name: ${name}

+

Email: ${email}

+

Store URL: ${storeUrl ? `${storeUrl}` : "not provided"}

+

Message:

+

${message.replace(/\n/g, "
")}

+ `, + }); + + 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.", + }; + } +} diff --git a/ui/app/(marketing)/signup/page.tsx b/ui/app/(marketing)/signup/page.tsx new file mode 100644 index 0000000..24f5f7e --- /dev/null +++ b/ui/app/(marketing)/signup/page.tsx @@ -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 ( +
+
+

Merchant access

+

Request access

+

+ Tell us about your store and we will get you set up. Once approved + you will receive your credentials by email. +

+ +
+
+ ); +} diff --git a/ui/app/(marketing)/signup/signup-form.tsx b/ui/app/(marketing)/signup/signup-form.tsx new file mode 100644 index 0000000..c1310fa --- /dev/null +++ b/ui/app/(marketing)/signup/signup-form.tsx @@ -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 ( + + ); +} + +export default function SignupForm() { + const [state, formAction] = useActionState(submitSignupRequest, initialState); + const [token, setToken] = useState(""); + const formRef = useRef(null); + + useEffect(() => { + if (state.success && formRef.current) { + formRef.current.reset(); + setToken(""); + } + }, [state.success]); + + return ( +
+
+ + + {state.errors?.name ? ( +

{state.errors.name}

+ ) : null} +
+ +
+ + + {state.errors?.email ? ( +

{state.errors.email}

+ ) : null} +
+ +
+ + +
+ +
+ +