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
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="font-semibold text-clay underline underline-offset-4"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Request access
|
||||
</Link>
|
||||
</p>
|
||||
</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",
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user