Add OPEN_REGISTRATION gate, signup request form, favicon

This commit is contained in:
jwinterm
2026-03-09 13:02:30 -04:00
parent 97b7aaa623
commit 341d83196b
14 changed files with 365 additions and 0 deletions
+11
View File
@@ -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
+1
View File
@@ -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()
+11
View File
@@ -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,
+9
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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 };
}
+100
View File
@@ -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.",
};
}
}
+22
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+11
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
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">
We never require more than view-only access to keep your funds safe.
</p>
<p className="mt-3 text-sm text-ink-soft">
Don&apos;t have an account?{" "}
<Link
href="/signup"
className="font-semibold text-clay underline underline-offset-4"
onClick={closeModal}
>
Request access
</Link>
</p>
</div>
</div>
);
+43
View File
@@ -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" }}
/>
);
}
+19
View File
@@ -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;
}
+2
View File
@@ -14,7 +14,9 @@
"next": "15.5.9",
"qrcode": "1.5.4",
"react": "19.2.3",
"@marsidev/react-turnstile": "^1.6.1",
"react-dom": "19.2.3",
"resend": "^4.1.2",
"tsconfig-paths": "^4.2.0"
},
"devDependencies": {