mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
Fork xmrcheckout for Wownero (WOW)
Minimal changes from upstream xmrcheckout to support Wownero: - Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD - Subaddress derivation: Wownero network byte overrides (53/63/54) - Payment URI scheme: wownero: - Dockerfile: Wownero binaries from Codeberg - Display text: Monero→Wownero, XMR→WOW throughout UI - Default daemon: 192.168.88.235:34568 - Added wownero-logo.svg File and folder structure kept identical to upstream for easy sync.
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
export type InvoiceState = {
|
||||
error: string | null;
|
||||
invoiceId: string | null;
|
||||
address: string | null;
|
||||
amount: string | null;
|
||||
recipientName: string | null;
|
||||
description: string | null;
|
||||
subaddressIndex: number | null;
|
||||
warnings: string[] | null;
|
||||
};
|
||||
|
||||
export type ApiKeyState = {
|
||||
apiKey: string;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type WebhookSecretState = {
|
||||
webhookSecret: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type WebhookFormState = {
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
};
|
||||
|
||||
export type ArchiveInvoiceState = {
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
archivedId: string | null;
|
||||
};
|
||||
|
||||
export type BtcpayCheckoutPreferenceState = {
|
||||
style: "standard" | "btcpay_classic";
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
};
|
||||
|
||||
export type DefaultConfirmationTargetState = {
|
||||
value: number;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
};
|
||||
|
||||
export type DefaultQrLogoState = {
|
||||
logo: "monero" | "none" | "custom";
|
||||
logoDataUrl: string | null;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
};
|
||||
|
||||
export type WebhookRedeliverState = {
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
};
|
||||
|
||||
const tourNoopMessage = "Tour mode only: no changes are saved.";
|
||||
|
||||
export async function createInvoiceTourAction(
|
||||
_prevState: InvoiceState,
|
||||
_formData: FormData
|
||||
): Promise<InvoiceState> {
|
||||
return {
|
||||
error: tourNoopMessage,
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveInvoiceTourAction(
|
||||
_prevState: ArchiveInvoiceState,
|
||||
formData: FormData
|
||||
): Promise<ArchiveInvoiceState> {
|
||||
const archivedId = String(formData.get("invoice_id") ?? "").trim() || null;
|
||||
return {
|
||||
error: null,
|
||||
success: tourNoopMessage,
|
||||
archivedId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebhookTourAction(
|
||||
_prevState: WebhookFormState,
|
||||
_formData: FormData
|
||||
): Promise<WebhookFormState> {
|
||||
return { error: null, success: tourNoopMessage };
|
||||
}
|
||||
|
||||
export async function deleteWebhookTourAction(
|
||||
_prevState: WebhookFormState,
|
||||
_formData: FormData
|
||||
): Promise<WebhookFormState> {
|
||||
return { error: null, success: tourNoopMessage };
|
||||
}
|
||||
|
||||
export async function redeliverWebhookDeliveryTourAction(
|
||||
_prevState: WebhookRedeliverState,
|
||||
_formData: FormData
|
||||
): Promise<WebhookRedeliverState> {
|
||||
return { error: null, success: tourNoopMessage };
|
||||
}
|
||||
|
||||
const webhookEventKeys = [
|
||||
{ event: "invoice.created", key: "invoice_created" },
|
||||
{ event: "invoice.payment_detected", key: "invoice_payment_detected" },
|
||||
{ event: "invoice.confirmed", key: "invoice_confirmed" },
|
||||
{ event: "invoice.expired", key: "invoice_expired" },
|
||||
] as const;
|
||||
|
||||
export async function createInvoiceAction(
|
||||
_prevState: InvoiceState,
|
||||
formData: FormData
|
||||
): Promise<InvoiceState> {
|
||||
const amountMode = String(formData.get("amount_mode") ?? "xmr").trim();
|
||||
const isFiat = amountMode === "fiat";
|
||||
const instantConfirmation = formData.get("instant_confirmation") === "1";
|
||||
const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim();
|
||||
let confirmationTarget: number | null = null;
|
||||
if (instantConfirmation) {
|
||||
confirmationTarget = 0;
|
||||
} else if (confirmationRaw) {
|
||||
const parsed = Number(confirmationRaw);
|
||||
if (Number.isNaN(parsed) || parsed < 0 || parsed > 10) {
|
||||
return {
|
||||
error: "Confirmation target must be between 0 and 10.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
confirmationTarget = parsed;
|
||||
}
|
||||
const amountRaw = String(
|
||||
formData.get(isFiat ? "amount_fiat" : "amount_xmr") ?? ""
|
||||
).trim();
|
||||
const amount = Number(amountRaw);
|
||||
if (Number.isNaN(amount) || amount <= 0) {
|
||||
return {
|
||||
error: isFiat ? "Enter a valid fiat amount." : "Enter a valid WOW amount.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
const currency = String(formData.get("currency") ?? "").trim().toUpperCase();
|
||||
if (isFiat && !currency) {
|
||||
return {
|
||||
error: "Currency is required when using a fiat amount.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
const recipientName = String(formData.get("recipient_name") ?? "").trim();
|
||||
const description = String(formData.get("description") ?? "").trim();
|
||||
const checkoutContinueUrlRaw = String(formData.get("checkout_continue_url") ?? "").trim();
|
||||
if (checkoutContinueUrlRaw) {
|
||||
try {
|
||||
const parsed = new URL(checkoutContinueUrlRaw);
|
||||
const isLocalhost =
|
||||
parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) {
|
||||
return {
|
||||
error: "Continue URL must use https (http is allowed for localhost).",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
error: "Continue URL must be a valid URL.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
|
||||
const expiresDate = String(formData.get("expires_date") ?? "").trim();
|
||||
const expiresTime = String(formData.get("expires_time") ?? "").trim();
|
||||
let expiresAt: string | null = null;
|
||||
if (!expiresAtRaw && expiresTime && !expiresDate) {
|
||||
return {
|
||||
error: "Expiry date must include a date.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
if (!expiresAtRaw && expiresDate) {
|
||||
const timeValue = expiresTime || "00:00";
|
||||
expiresAt = `${expiresDate}T${timeValue}`;
|
||||
} else if (expiresAtRaw) {
|
||||
const normalized =
|
||||
expiresAtRaw.length === 10 || expiresAtRaw.endsWith("T")
|
||||
? `${expiresAtRaw.replace(/T$/, "")}T00:00`
|
||||
: expiresAtRaw;
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return {
|
||||
error: "Expiry date must be a valid date and time.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
expiresAt = parsed.toISOString();
|
||||
}
|
||||
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "API key not found. Please sign in again.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {};
|
||||
if (recipientName) {
|
||||
metadata.recipient_name = recipientName;
|
||||
}
|
||||
if (description) {
|
||||
metadata.description = description;
|
||||
}
|
||||
const qrLogoModeRaw = String(formData.get("qr_logo_mode") ?? "").trim();
|
||||
const qrLogoDataUrl = String(formData.get("qr_logo_data_url") ?? "").trim();
|
||||
const qrLogoMode =
|
||||
qrLogoModeRaw === "account_default" ||
|
||||
qrLogoModeRaw === "monero" ||
|
||||
qrLogoModeRaw === "none" ||
|
||||
qrLogoModeRaw === "custom"
|
||||
? qrLogoModeRaw
|
||||
: "account_default";
|
||||
const qrPayload =
|
||||
qrLogoMode === "account_default"
|
||||
? null
|
||||
: qrLogoMode === "custom"
|
||||
? qrLogoDataUrl
|
||||
? { logo: "custom", logo_data_url: qrLogoDataUrl }
|
||||
: null
|
||||
: { logo: qrLogoMode };
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/invoices`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount_xmr: isFiat ? null : amountRaw,
|
||||
amount_fiat: isFiat ? amountRaw : null,
|
||||
currency: isFiat ? currency : null,
|
||||
...(confirmationTarget !== null ? { confirmation_target: confirmationTarget } : {}),
|
||||
expires_at: expiresAt,
|
||||
checkout_continue_url: checkoutContinueUrlRaw || null,
|
||||
metadata:
|
||||
Object.keys(metadata).length > 0 || qrPayload
|
||||
? {
|
||||
...(Object.keys(metadata).length > 0 ? metadata : {}),
|
||||
...(qrPayload ? { qr: qrPayload } : {}),
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to create invoice.";
|
||||
return {
|
||||
error: message,
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
id: string;
|
||||
address: string;
|
||||
subaddress_index?: number | null;
|
||||
amount_xmr: string;
|
||||
warnings?: string[] | null;
|
||||
};
|
||||
return {
|
||||
error: null,
|
||||
invoiceId: data.id,
|
||||
address: data.address,
|
||||
amount: data.amount_xmr,
|
||||
recipientName: recipientName || null,
|
||||
description: description || null,
|
||||
subaddressIndex: data.subaddress_index ?? null,
|
||||
warnings: data.warnings ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetApiKeyAction(
|
||||
_prevState: ApiKeyState
|
||||
): Promise<ApiKeyState> {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return { apiKey: _prevState.apiKey, error: "API key not found. Please sign in again." };
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ reset_api_key: true }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to reset API key.";
|
||||
return { apiKey: _prevState.apiKey, error: message };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { api_key?: string | null };
|
||||
if (!data.api_key) {
|
||||
return { apiKey: _prevState.apiKey, error: "API key reset did not return a key." };
|
||||
}
|
||||
|
||||
// Update the cookie with the new API key
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wowcheckout_api_key", data.api_key, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { apiKey: data.api_key, error: null };
|
||||
}
|
||||
|
||||
export async function resetWebhookSecretAction(
|
||||
_prevState: WebhookSecretState
|
||||
): Promise<WebhookSecretState> {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
webhookSecret: _prevState.webhookSecret,
|
||||
error: "API key not found. Please sign in again.",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ reset_webhook_secret: true }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to reset webhook secret.";
|
||||
return { webhookSecret: _prevState.webhookSecret, error: message };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { webhook_secret?: string | null };
|
||||
if (!data.webhook_secret) {
|
||||
return {
|
||||
webhookSecret: _prevState.webhookSecret,
|
||||
error: "Webhook secret reset did not return a secret.",
|
||||
};
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { webhookSecret: data.webhook_secret, error: null };
|
||||
}
|
||||
|
||||
export async function updateBtcpayCheckoutStyleAction(
|
||||
_prevState: BtcpayCheckoutPreferenceState,
|
||||
formData: FormData
|
||||
): Promise<BtcpayCheckoutPreferenceState> {
|
||||
const nextStyle = String(formData.get("btcpay_checkout_style") ?? "").trim();
|
||||
if (nextStyle !== "standard" && nextStyle !== "btcpay_classic") {
|
||||
return {
|
||||
style: _prevState.style,
|
||||
error: "Select a valid BTCPay checkout layout.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
style: _prevState.style,
|
||||
error: "API key not found. Please sign in again.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ btcpay_checkout_style: nextStyle }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to update BTCPay preference.";
|
||||
return { style: _prevState.style, error: message, success: null };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { btcpay_checkout_style?: string | null };
|
||||
const resolvedStyle =
|
||||
data.btcpay_checkout_style === "btcpay_classic" ||
|
||||
data.btcpay_checkout_style === "standard"
|
||||
? data.btcpay_checkout_style
|
||||
: nextStyle;
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
style: resolvedStyle as "standard" | "btcpay_classic",
|
||||
error: null,
|
||||
success: "Preference saved.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateDefaultConfirmationTargetAction(
|
||||
_prevState: DefaultConfirmationTargetState,
|
||||
formData: FormData
|
||||
): Promise<DefaultConfirmationTargetState> {
|
||||
const valueRaw = String(formData.get("default_confirmation_target") ?? "").trim();
|
||||
const parsed = Number(valueRaw);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) {
|
||||
return {
|
||||
value: _prevState.value,
|
||||
error: "Default confirmation target must be an integer between 0 and 10.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
value: _prevState.value,
|
||||
error: "API key not found. Please sign in again.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ default_confirmation_target: parsed }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to update the default confirmation target.";
|
||||
return { value: _prevState.value, error: message, success: null };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { default_confirmation_target?: number | null };
|
||||
const resolvedValue =
|
||||
typeof data.default_confirmation_target === "number"
|
||||
? data.default_confirmation_target
|
||||
: parsed;
|
||||
revalidatePath("/dashboard");
|
||||
return { value: resolvedValue, error: null, success: "Default updated." };
|
||||
}
|
||||
|
||||
export async function updateDefaultQrLogoAction(
|
||||
_prevState: DefaultQrLogoState,
|
||||
formData: FormData
|
||||
): Promise<DefaultQrLogoState> {
|
||||
const logo = String(formData.get("default_qr_logo") ?? "").trim();
|
||||
const logoDataUrlRaw = String(formData.get("default_qr_logo_data_url") ?? "").trim();
|
||||
const normalizedLogo =
|
||||
logo === "none" || logo === "custom" || logo === "monero" ? logo : null;
|
||||
if (!normalizedLogo) {
|
||||
return {
|
||||
logo: _prevState.logo,
|
||||
logoDataUrl: _prevState.logoDataUrl,
|
||||
error: "Select a valid QR logo mode.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const logoDataUrl =
|
||||
normalizedLogo === "custom" ? (logoDataUrlRaw ? logoDataUrlRaw : null) : null;
|
||||
if (normalizedLogo === "custom" && !logoDataUrl) {
|
||||
return {
|
||||
logo: _prevState.logo,
|
||||
logoDataUrl: _prevState.logoDataUrl,
|
||||
error: "Upload a logo image for custom mode.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
logo: _prevState.logo,
|
||||
logoDataUrl: _prevState.logoDataUrl,
|
||||
error: "API key not found. Please sign in again.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
default_qr_logo: normalizedLogo,
|
||||
default_qr_logo_data_url: logoDataUrl,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to update QR settings.";
|
||||
return {
|
||||
logo: _prevState.logo,
|
||||
logoDataUrl: _prevState.logoDataUrl,
|
||||
error: message,
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
default_qr_logo?: string | null;
|
||||
default_qr_logo_data_url?: string | null;
|
||||
};
|
||||
const resolvedLogo =
|
||||
data.default_qr_logo === "none" ||
|
||||
data.default_qr_logo === "custom" ||
|
||||
data.default_qr_logo === "monero"
|
||||
? data.default_qr_logo
|
||||
: normalizedLogo;
|
||||
const resolvedDataUrl =
|
||||
resolvedLogo === "custom" ? data.default_qr_logo_data_url ?? logoDataUrl : null;
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
logo: resolvedLogo as "monero" | "none" | "custom",
|
||||
logoDataUrl: resolvedDataUrl ?? null,
|
||||
error: null,
|
||||
success: "QR default saved.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebhookAction(
|
||||
_prevState: WebhookFormState,
|
||||
formData: FormData
|
||||
): Promise<WebhookFormState> {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return { error: "API key not found. Please sign in again.", success: null };
|
||||
}
|
||||
|
||||
const defaultUrl = String(formData.get("default_url") ?? "").trim();
|
||||
const events: string[] = [];
|
||||
const eventUrls: Record<string, string> = {};
|
||||
|
||||
webhookEventKeys.forEach(({ event, key }) => {
|
||||
const enabled = formData.get(`event_${key}`) === "on";
|
||||
const override = String(formData.get(`event_url_${key}`) ?? "").trim();
|
||||
if (enabled || override) {
|
||||
events.push(event);
|
||||
}
|
||||
if (override) {
|
||||
eventUrls[event] = override;
|
||||
}
|
||||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
return { error: "Select at least one invoice event.", success: null };
|
||||
}
|
||||
|
||||
if (!defaultUrl) {
|
||||
const missing = events.filter((event) => !eventUrls[event]);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
error: "Provide a default URL or an override URL for each selected event.",
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const payload: {
|
||||
url?: string;
|
||||
events?: string[];
|
||||
event_urls?: Record<string, string>;
|
||||
} = {
|
||||
events,
|
||||
};
|
||||
|
||||
if (defaultUrl) {
|
||||
payload.url = defaultUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(eventUrls).length > 0) {
|
||||
payload.event_urls = eventUrls;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to create webhook.";
|
||||
return { error: message, success: null };
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { error: null, success: "Webhook saved." };
|
||||
}
|
||||
|
||||
export async function deleteWebhookAction(
|
||||
_prevState: WebhookFormState,
|
||||
formData: FormData
|
||||
): Promise<WebhookFormState> {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return { error: "API key not found. Please sign in again.", success: null };
|
||||
}
|
||||
|
||||
const webhookId = String(formData.get("webhook_id") ?? "").trim();
|
||||
if (!webhookId) {
|
||||
return { error: "Webhook id missing.", success: null };
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/webhooks/${webhookId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to remove webhook.";
|
||||
return { error: message, success: null };
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { error: null, success: "Webhook removed." };
|
||||
}
|
||||
|
||||
export async function archiveInvoiceAction(
|
||||
_prevState: ArchiveInvoiceState,
|
||||
formData: FormData
|
||||
): Promise<ArchiveInvoiceState> {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "API key not found. Please sign in again.",
|
||||
success: null,
|
||||
archivedId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const invoiceId = String(formData.get("invoice_id") ?? "").trim();
|
||||
if (!invoiceId) {
|
||||
return { error: "Invoice id missing.", success: null, archivedId: null };
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/invoices/${invoiceId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to archive invoice.";
|
||||
return { error: message, success: null, archivedId: null };
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { error: null, success: "Invoice archived until expiry.", archivedId: invoiceId };
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wowcheckout_api_key", "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
cookieStore.set("wowcheckout_webhook_secret", "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function redeliverWebhookDeliveryAction(
|
||||
_prevState: WebhookRedeliverState,
|
||||
formData: FormData
|
||||
): Promise<WebhookRedeliverState> {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return { error: "API key not found. Please sign in again.", success: null };
|
||||
}
|
||||
|
||||
const deliveryId = String(formData.get("delivery_id") ?? "").trim();
|
||||
if (!deliveryId) {
|
||||
return { error: "Delivery id missing.", success: null };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/api/core/webhooks/deliveries/${encodeURIComponent(deliveryId)}/redeliver`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `ApiKey ${apiKey}` },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to redeliver the webhook.";
|
||||
return { error: message, success: null };
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { error: null, success: "Redelivery queued." };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
if (!apiKey) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const sourceUrl = new URL(request.url);
|
||||
const targetUrl = new URL(`${apiBaseUrl}/api/core/invoices/export.csv`);
|
||||
sourceUrl.searchParams.forEach((value, key) => {
|
||||
targetUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(targetUrl.toString(), {
|
||||
headers: { Authorization: `ApiKey ${apiKey}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "Unable to export invoices.");
|
||||
return new Response(message, { status: response.status });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", response.headers.get("content-type") ?? "text/csv");
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
disposition ?? 'attachment; filename="invoices.csv"'
|
||||
);
|
||||
|
||||
return new Response(response.body, { status: 200, headers });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import ApiKeySection from "../../../components/api-key-section";
|
||||
import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-style-section";
|
||||
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
|
||||
import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
|
||||
import InvoicePanel from "../../../components/invoice-panel";
|
||||
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
|
||||
import WebhookSecretSection from "../../../components/webhook-secret-section";
|
||||
import WebhookSection from "../../../components/webhook-section";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
};
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
||||
const tabs = ["overview", "invoices", "webhooks", "profile"] as const;
|
||||
const webhookTabs = ["settings", "history"] as const;
|
||||
type DashboardTab = (typeof tabs)[number];
|
||||
type WebhookTab = (typeof webhookTabs)[number];
|
||||
|
||||
type DashboardSearchParams = Record<string, string | string[] | undefined>;
|
||||
type InvoiceSummary = {
|
||||
id: string;
|
||||
address: string;
|
||||
subaddress_index?: number | null;
|
||||
amount_xmr: string;
|
||||
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
|
||||
confirmation_target: number;
|
||||
paid_after_expiry?: boolean;
|
||||
paid_after_expiry_at?: string | null;
|
||||
created_at: string;
|
||||
archived_at: string | null;
|
||||
detected_at: string | null;
|
||||
confirmed_at: string | null;
|
||||
expires_at: string | null;
|
||||
};
|
||||
type WebhookSummary = {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
event_urls?: Record<string, string> | null;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
type WebhookDeliverySummary = {
|
||||
id: string;
|
||||
webhook_id: string | null;
|
||||
event: string;
|
||||
url: string;
|
||||
invoice_id: string | null;
|
||||
invoice_address: string | null;
|
||||
invoice_subaddress_index: number | null;
|
||||
invoice_amount_xmr: string | null;
|
||||
invoice_status:
|
||||
| "pending"
|
||||
| "payment_detected"
|
||||
| "confirmed"
|
||||
| "expired"
|
||||
| "invalid"
|
||||
| null;
|
||||
http_status: number | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
type ProfileSummary = {
|
||||
id: string;
|
||||
payment_address: string;
|
||||
default_confirmation_target: number;
|
||||
default_qr_logo: "monero" | "none" | "custom";
|
||||
default_qr_logo_data_url: string | null;
|
||||
btcpay_checkout_style?: "standard" | "btcpay_classic";
|
||||
created_at: string;
|
||||
};
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<DashboardSearchParams>;
|
||||
}) {
|
||||
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
||||
const webhookSecret = (await cookies()).get("wowcheckout_webhook_secret")?.value ?? null;
|
||||
if (!apiKey) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const tabParam = resolvedSearchParams?.tab;
|
||||
const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam;
|
||||
const activeTab: DashboardTab = tabs.includes(activeTabValue as DashboardTab)
|
||||
? (activeTabValue as DashboardTab)
|
||||
: "overview";
|
||||
const webhookTabParam = resolvedSearchParams?.webhook_tab;
|
||||
const activeWebhookTabValue = Array.isArray(webhookTabParam)
|
||||
? webhookTabParam[0]
|
||||
: webhookTabParam;
|
||||
const activeWebhookTab: WebhookTab = webhookTabs.includes(
|
||||
activeWebhookTabValue as WebhookTab
|
||||
)
|
||||
? (activeWebhookTabValue as WebhookTab)
|
||||
: "settings";
|
||||
const includeArchived =
|
||||
activeTab === "invoices" &&
|
||||
(resolvedSearchParams?.archived === "1" ||
|
||||
resolvedSearchParams?.archived === "true");
|
||||
const qParam = resolvedSearchParams?.q;
|
||||
const queryValue = Array.isArray(qParam) ? qParam[0] : qParam;
|
||||
const invoiceSearchQuery = activeTab === "invoices" ? (queryValue ?? "") : "";
|
||||
const sortParam = resolvedSearchParams?.sort;
|
||||
const sortValue = Array.isArray(sortParam) ? sortParam[0] : sortParam;
|
||||
const invoiceSort =
|
||||
activeTab === "invoices" && typeof sortValue === "string" && sortValue
|
||||
? sortValue
|
||||
: "created_at";
|
||||
const orderParam = resolvedSearchParams?.order;
|
||||
const orderValue = Array.isArray(orderParam) ? orderParam[0] : orderParam;
|
||||
const invoiceOrder =
|
||||
activeTab === "invoices" && typeof orderValue === "string" && orderValue
|
||||
? orderValue
|
||||
: "desc";
|
||||
|
||||
// API key is already available from the cookie
|
||||
const apiKeyData = activeTab === "profile" ? { api_key: apiKey } : null;
|
||||
let webhooksData: WebhookSummary[] = [];
|
||||
let webhookHistory: WebhookDeliverySummary[] = [];
|
||||
|
||||
const invoiceLimit = 100;
|
||||
let invoicesData: { items: InvoiceSummary[]; total: number } | null = null;
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
const items: InvoiceSummary[] = [];
|
||||
do {
|
||||
const invoiceUrl = new URL(`${apiBaseUrl}/api/core/invoices`);
|
||||
invoiceUrl.searchParams.set("limit", invoiceLimit.toString());
|
||||
invoiceUrl.searchParams.set("offset", offset.toString());
|
||||
if (includeArchived) {
|
||||
invoiceUrl.searchParams.set("include_archived", "true");
|
||||
}
|
||||
if (invoiceSearchQuery) {
|
||||
invoiceUrl.searchParams.set("q", invoiceSearchQuery);
|
||||
}
|
||||
if (invoiceSort) {
|
||||
invoiceUrl.searchParams.set("sort", invoiceSort);
|
||||
}
|
||||
if (invoiceOrder) {
|
||||
invoiceUrl.searchParams.set("order", invoiceOrder);
|
||||
}
|
||||
const response = await fetch(invoiceUrl.toString(), {
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
if (!response.ok) {
|
||||
break;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
items: InvoiceSummary[];
|
||||
total: number;
|
||||
};
|
||||
total = data.total;
|
||||
items.push(...data.items);
|
||||
offset += invoiceLimit;
|
||||
} while (offset < total);
|
||||
invoicesData = { items, total };
|
||||
|
||||
if (
|
||||
activeTab === "overview" ||
|
||||
(activeTab === "webhooks" && activeWebhookTab === "settings")
|
||||
) {
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
if (response.ok) {
|
||||
webhooksData = (await response.json()) as WebhookSummary[];
|
||||
}
|
||||
}
|
||||
if (activeTab === "webhooks" && activeWebhookTab === "history") {
|
||||
const historyUrl = new URL(`${apiBaseUrl}/api/core/webhooks/history`);
|
||||
historyUrl.searchParams.set("limit", "50");
|
||||
const response = await fetch(historyUrl.toString(), {
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
if (response.ok) {
|
||||
webhookHistory = (await response.json()) as WebhookDeliverySummary[];
|
||||
}
|
||||
}
|
||||
|
||||
const allInvoices = invoicesData?.items ?? [];
|
||||
let profileData: ProfileSummary | null = null;
|
||||
|
||||
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
if (response.ok) {
|
||||
profileData = (await response.json()) as ProfileSummary;
|
||||
}
|
||||
}
|
||||
|
||||
const isToday = (value: string) => {
|
||||
const date = new Date(value);
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate()
|
||||
);
|
||||
};
|
||||
|
||||
const invoicesTodayCount = allInvoices.filter((invoice) =>
|
||||
isToday(invoice.created_at)
|
||||
).length;
|
||||
const awaitingConfirmationCount = allInvoices.filter(
|
||||
(invoice) => invoice.status === "payment_detected"
|
||||
).length;
|
||||
const confirmedCount = allInvoices.filter(
|
||||
(invoice) => invoice.status === "confirmed"
|
||||
).length;
|
||||
|
||||
const tabBaseClass =
|
||||
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
|
||||
const tabActiveClass =
|
||||
"border-ink bg-ink text-cream shadow-[0_12px_24px_rgba(16,18,23,0.18)]";
|
||||
const subTabBaseClass =
|
||||
"inline-flex items-center rounded-full border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
|
||||
const subTabActiveClass =
|
||||
"border-ink bg-ink text-cream shadow-[0_10px_18px_rgba(16,18,23,0.18)]";
|
||||
|
||||
return (
|
||||
<main className="px-[6vw] py-12 text-ink">
|
||||
<section className="mx-auto grid w-full max-w-6xl gap-8 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside className="grid gap-6">
|
||||
<nav className="flex flex-col gap-2" aria-label="Dashboard sections">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab}
|
||||
className={`${tabBaseClass} ${activeTab === tab ? tabActiveClass : ""}`}
|
||||
href={`/dashboard?tab=${tab}`}
|
||||
>
|
||||
{tab}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="grid gap-6">
|
||||
{activeTab === "overview" ? (
|
||||
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
||||
<h1 className="font-serif text-3xl">Operational overview.</h1>
|
||||
<p className="mt-2 text-ink-soft">
|
||||
Track invoice flow and confirmation progress at a glance.
|
||||
</p>
|
||||
<div className="mt-6 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||
Primary address
|
||||
</p>
|
||||
<p className="mt-3 break-all font-mono text-sm text-ink">
|
||||
{profileData?.payment_address ?? "Unavailable"}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-ink-soft">
|
||||
This is the wallet address used for your invoices.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
title: "Invoices today",
|
||||
value: invoicesTodayCount.toString(),
|
||||
detail: "Totals will appear once invoices are created.",
|
||||
},
|
||||
{
|
||||
title: "Invoices awaiting confirmation",
|
||||
value: awaitingConfirmationCount.toString(),
|
||||
detail: "Waiting for the configured confirmation target.",
|
||||
},
|
||||
{
|
||||
title: "Confirmed invoices",
|
||||
value: confirmedCount.toString(),
|
||||
detail: "Confirmed on-chain invoices will display here.",
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||
{item.title}
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold">{item.value}</h2>
|
||||
<p className="mt-2 text-sm text-ink-soft">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||
Webhook endpoints
|
||||
</p>
|
||||
{webhooksData.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-ink-soft">
|
||||
No webhooks configured yet.{" "}
|
||||
<Link className="font-semibold text-ink underline" href="/dashboard?tab=webhooks">
|
||||
Add a webhook endpoint.
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3 text-sm text-ink">
|
||||
{webhooksData.map((webhook) => (
|
||||
<div
|
||||
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
|
||||
key={webhook.id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="break-all font-semibold">{webhook.url}</p>
|
||||
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
|
||||
{webhook.active ? "Active" : "Paused"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-ink-soft">
|
||||
Events: {webhook.events.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
|
||||
We never hold funds. All payments move from the customer to your
|
||||
wallet.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab === "invoices" ? (
|
||||
<InvoicePanel
|
||||
activeInvoices={allInvoices}
|
||||
includeArchived={includeArchived}
|
||||
searchQuery={invoiceSearchQuery}
|
||||
sort={invoiceSort}
|
||||
order={invoiceOrder}
|
||||
defaultConfirmationTarget={profileData?.default_confirmation_target ?? 1}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "profile" ? (
|
||||
<>
|
||||
{apiKeyData ? (
|
||||
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl">Profile settings.</h1>
|
||||
<p className="mt-2 text-ink-soft">
|
||||
Manage credentials and delivery settings for your integration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
||||
<h2 className="font-serif text-2xl">Store id</h2>
|
||||
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
|
||||
Single-store per primary address. Use this id in the BTCPay
|
||||
WooCommerce plugin store field.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<p className="break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
|
||||
{profileData?.id ?? "Unavailable"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
||||
<h2 className="font-serif text-2xl">API key</h2>
|
||||
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
|
||||
Use this key with authenticated endpoints.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ApiKeySection apiKey={apiKeyData.api_key} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
||||
<h2 className="font-serif text-2xl">Webhook secret</h2>
|
||||
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
|
||||
Sent with each delivery as the{" "}
|
||||
<code>X-Webhook-Secret</code> header.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<WebhookSecretSection webhookSecret={webhookSecret} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
||||
<BtcpayCheckoutStyleSection
|
||||
initialStyle={
|
||||
profileData?.btcpay_checkout_style === "btcpay_classic"
|
||||
? "btcpay_classic"
|
||||
: "btcpay_classic"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
||||
<DefaultConfirmationTargetSection
|
||||
initialValue={profileData?.default_confirmation_target ?? 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
||||
<DefaultQrLogoSection
|
||||
initialLogo={profileData?.default_qr_logo ?? "monero"}
|
||||
initialLogoDataUrl={profileData?.default_qr_logo_data_url ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
||||
<h1 className="font-serif text-3xl">API key</h1>
|
||||
<p className="mt-2 text-ink-soft">
|
||||
Sign in again to reveal your API key.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{activeTab === "webhooks" ? (
|
||||
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl">Webhook endpoints.</h1>
|
||||
<p className="mt-2 text-ink-soft">
|
||||
Relay invoice state updates to your systems. Optional overrides let
|
||||
you target different URLs per event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Webhook views">
|
||||
{webhookTabs.map((tab) => (
|
||||
<Link
|
||||
key={tab}
|
||||
className={`${subTabBaseClass} ${
|
||||
activeWebhookTab === tab ? subTabActiveClass : ""
|
||||
}`}
|
||||
href={`/dashboard?tab=webhooks&webhook_tab=${tab}`}
|
||||
>
|
||||
{tab}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{activeWebhookTab === "settings" ? (
|
||||
<div className="mt-6">
|
||||
<WebhookSection webhooks={webhooksData} />
|
||||
</div>
|
||||
) : null}
|
||||
{activeWebhookTab === "history" ? (
|
||||
<div className="mt-6">
|
||||
<WebhookHistoryPanel deliveries={webhookHistory} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import "../(marketing)/marketing.css";
|
||||
import DonateModal from "../../components/donate-modal";
|
||||
import NavLink from "../../components/nav-link";
|
||||
import { logoutAction } from "./dashboard/actions";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<header className="site-header">
|
||||
<div className="brand">
|
||||
<Link href="/" aria-label="WOW Checkout home">
|
||||
<Image
|
||||
className="brand-logo"
|
||||
src="/logo.png"
|
||||
alt="WOW Checkout logo"
|
||||
width={128}
|
||||
height={128}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<Link className="name" href="/">
|
||||
WOW Checkout
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
<NavLink href="/invoice">Check Invoice</NavLink>
|
||||
<NavLink href="/docs">Documentation</NavLink>
|
||||
<NavLink href="/faq">FAQ</NavLink>
|
||||
<NavLink href="/dashboard">Dashboard</NavLink>
|
||||
<form action={logoutAction}>
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
{children}
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer-copy">
|
||||
<p>wowcheckout.com - Non-custodial Wownero checkout software.</p>
|
||||
<p>
|
||||
<Link
|
||||
className="underline underline-offset-4"
|
||||
href="https://github.com/wowcheckout/wowcheckout"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<DonateModal />
|
||||
</Suspense>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
export type LoginState = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export async function loginAction(
|
||||
_prevState: LoginState,
|
||||
formData: FormData
|
||||
): Promise<LoginState> {
|
||||
const paymentAddress = String(formData.get("payment_address") ?? "").trim();
|
||||
const viewKey = String(formData.get("view_key") ?? "").trim();
|
||||
|
||||
if (!paymentAddress || !viewKey) {
|
||||
return { error: "Primary address and secret view key are required." };
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message = detail?.detail ?? "Unable to sign in.";
|
||||
return { error: message };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
api_key: string;
|
||||
webhook_secret: string;
|
||||
store_id?: string;
|
||||
};
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wowcheckout_api_key", data.api_key, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
});
|
||||
cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
|
||||
import { loginAction, type LoginState } from "./actions";
|
||||
|
||||
const initialState: LoginState = { error: null };
|
||||
|
||||
const apiBaseUrl =
|
||||
typeof window === "undefined" ? process.env.NEXT_PUBLIC_API_BASE_URL ?? "" : "";
|
||||
const base58Pattern = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
|
||||
const viewKeyPattern = /^[0-9a-fA-F]{64}$/;
|
||||
const validationDelayMs = 450;
|
||||
|
||||
const isLikelyPrimaryAddress = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length !== 95) {
|
||||
return false;
|
||||
}
|
||||
if (!trimmed.startsWith("4")) {
|
||||
return false;
|
||||
}
|
||||
return base58Pattern.test(trimmed);
|
||||
};
|
||||
|
||||
const isLikelySecretViewKey = (value: string) => viewKeyPattern.test(value.trim());
|
||||
|
||||
export default function LoginForm() {
|
||||
const [state, formAction] = useFormState(loginAction, initialState);
|
||||
const [paymentAddress, setPaymentAddress] = useState("");
|
||||
const [viewKey, setViewKey] = useState("");
|
||||
const [validationStatus, setValidationStatus] = useState<
|
||||
"idle" | "checking" | "valid" | "invalid" | "error"
|
||||
>("idle");
|
||||
const [validationMessage, setValidationMessage] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
|
||||
return false;
|
||||
}
|
||||
return validationStatus !== "checking" && validationStatus !== "invalid";
|
||||
}, [paymentAddress, validationStatus, viewKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
|
||||
setValidationStatus("idle");
|
||||
setValidationMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setValidationStatus("checking");
|
||||
setValidationMessage(null);
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/core/auth/validate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
payment_address: paymentAddress.trim(),
|
||||
view_key: viewKey.trim(),
|
||||
}),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const message =
|
||||
detail?.detail ?? "Address and view key did not pass validation.";
|
||||
setValidationStatus("invalid");
|
||||
setValidationMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationStatus("valid");
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setValidationStatus("error");
|
||||
setValidationMessage(
|
||||
"Unable to validate the address and view key yet. You can still sign in to confirm."
|
||||
);
|
||||
}
|
||||
}, validationDelayMs);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [paymentAddress, viewKey]);
|
||||
|
||||
return (
|
||||
<form className="mt-6 grid gap-5" action={formAction}>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||
htmlFor="payment_address"
|
||||
>
|
||||
Primary address
|
||||
</label>
|
||||
{isLikelyPrimaryAddress(paymentAddress) ? (
|
||||
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Valid
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-[96px] 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="payment_address"
|
||||
name="payment_address"
|
||||
rows={3}
|
||||
autoComplete="off"
|
||||
required
|
||||
value={paymentAddress}
|
||||
onChange={(event) => setPaymentAddress(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||
htmlFor="view_key"
|
||||
>
|
||||
Secret view key
|
||||
</label>
|
||||
{isLikelySecretViewKey(viewKey) ? (
|
||||
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Valid
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<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="view_key"
|
||||
name="view_key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
required
|
||||
value={viewKey}
|
||||
onChange={(event) => setViewKey(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{validationStatus === "checking" ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">
|
||||
Checking address and view key
|
||||
</p>
|
||||
) : null}
|
||||
{validationStatus === "valid" ? (
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
|
||||
Address and view key match
|
||||
</p>
|
||||
) : null}
|
||||
{validationStatus === "invalid" || validationStatus === "error" ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||
{validationMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{state.error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end">
|
||||
<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={!canSubmit}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Welcome",
|
||||
};
|
||||
|
||||
export default async function WelcomePage() {
|
||||
const session = (await cookies()).get("wowcheckout_session")?.value;
|
||||
if (!session) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
redirect("/dashboard");
|
||||
}
|
||||
Reference in New Issue
Block a user