From c92dfd2ef1a6efac253c5fb27b4423d4400c7d54 Mon Sep 17 00:00:00 2001 From: xmrcheckout Date: Sun, 4 Jan 2026 15:46:26 +0100 Subject: [PATCH] Add UI --- ui/.env.example | 4 + ui/.eslintrc.cjs | 3 + ui/Dockerfile | 22 + ui/app/(app)/dashboard/actions.ts | 707 ++ ui/app/(app)/dashboard/invoices.csv/route.ts | 37 + ui/app/(app)/dashboard/page.tsx | 481 ++ ui/app/(app)/layout.tsx | 61 + ui/app/(app)/login/actions.ts | 57 + ui/app/(app)/login/login-form.tsx | 198 + ui/app/(app)/login/page.tsx | 10 + ui/app/(app)/welcome/page.tsx | 15 + ui/app/(marketing)/docs/integrations/page.tsx | 553 ++ ui/app/(marketing)/docs/page.tsx | 840 +++ .../(marketing)/donate/[invoiceId]/page.tsx | 331 + ui/app/(marketing)/donate/actions.ts | 38 + ui/app/(marketing)/donate/page.tsx | 14 + ui/app/(marketing)/faq/page.tsx | 289 + .../(marketing)/invoice/[invoiceId]/page.tsx | 333 + ui/app/(marketing)/invoice/page.tsx | 84 + ui/app/(marketing)/layout.tsx | 88 + ui/app/(marketing)/marketing.css | 1098 +++ ui/app/(marketing)/page.tsx | 270 + ui/app/globals.css | 22 + ui/app/i/[invoiceId]/page.tsx | 254 + ui/app/layout.tsx | 35 + ui/app/robots.ts | 18 + ui/app/sitemap.ts | 41 + ui/components/api-key-card.tsx | 43 + ui/components/api-key-section.tsx | 38 + .../btcpay-checkout-style-section.tsx | 84 + ui/components/btcpay-classic-checkout.tsx | 265 + ui/components/btcpay-modal-bridge.tsx | 48 + ui/components/btcpay-modal-close-button.tsx | 20 + ui/components/create-invoice-card.tsx | 387 + .../default-confirmation-target-section.tsx | 85 + ui/components/default-qr-logo-section.tsx | 179 + ui/components/donate-modal.tsx | 195 + ui/components/donation-status-actions.tsx | 16 + ui/components/invoice-panel.tsx | 935 +++ ui/components/invoice-payment-details.tsx | 256 + ui/components/invoice-status-actions.tsx | 16 + ui/components/invoice-status-auto-refresh.tsx | 23 + ui/components/invoice-status-lookup.tsx | 76 + ui/components/login-modal.tsx | 122 + ui/components/relative-time.ts | 51 + ui/components/secret-card.tsx | 45 + ui/components/status-refresh-button.tsx | 34 + ui/components/webhook-history-panel.tsx | 184 + ui/components/webhook-secret-section.tsx | 56 + ui/components/webhook-section.tsx | 175 + ui/lib/formatting.ts | 24 + ui/lib/use-xmr-usd-rate.ts | 70 + ui/next-env.d.ts | 6 + ui/next.config.mjs | 6 + ui/package-lock.json | 6339 +++++++++++++++++ ui/package.json | 29 + ui/postcss.config.cjs | 6 + ui/public/logo.png | Bin 0 -> 1478286 bytes ui/public/modal/btcpay.js | 136 + ui/public/monero-logo.svg | 13 + ui/tailwind.config.ts | 41 + ui/tsconfig.json | 39 + 62 files changed, 15945 insertions(+) create mode 100644 ui/.env.example create mode 100644 ui/.eslintrc.cjs create mode 100644 ui/Dockerfile create mode 100644 ui/app/(app)/dashboard/actions.ts create mode 100644 ui/app/(app)/dashboard/invoices.csv/route.ts create mode 100644 ui/app/(app)/dashboard/page.tsx create mode 100644 ui/app/(app)/layout.tsx create mode 100644 ui/app/(app)/login/actions.ts create mode 100644 ui/app/(app)/login/login-form.tsx create mode 100644 ui/app/(app)/login/page.tsx create mode 100644 ui/app/(app)/welcome/page.tsx create mode 100644 ui/app/(marketing)/docs/integrations/page.tsx create mode 100644 ui/app/(marketing)/docs/page.tsx create mode 100644 ui/app/(marketing)/donate/[invoiceId]/page.tsx create mode 100644 ui/app/(marketing)/donate/actions.ts create mode 100644 ui/app/(marketing)/donate/page.tsx create mode 100644 ui/app/(marketing)/faq/page.tsx create mode 100644 ui/app/(marketing)/invoice/[invoiceId]/page.tsx create mode 100644 ui/app/(marketing)/invoice/page.tsx create mode 100644 ui/app/(marketing)/layout.tsx create mode 100644 ui/app/(marketing)/marketing.css create mode 100644 ui/app/(marketing)/page.tsx create mode 100644 ui/app/globals.css create mode 100644 ui/app/i/[invoiceId]/page.tsx create mode 100644 ui/app/layout.tsx create mode 100644 ui/app/robots.ts create mode 100644 ui/app/sitemap.ts create mode 100644 ui/components/api-key-card.tsx create mode 100644 ui/components/api-key-section.tsx create mode 100644 ui/components/btcpay-checkout-style-section.tsx create mode 100644 ui/components/btcpay-classic-checkout.tsx create mode 100644 ui/components/btcpay-modal-bridge.tsx create mode 100644 ui/components/btcpay-modal-close-button.tsx create mode 100644 ui/components/create-invoice-card.tsx create mode 100644 ui/components/default-confirmation-target-section.tsx create mode 100644 ui/components/default-qr-logo-section.tsx create mode 100644 ui/components/donate-modal.tsx create mode 100644 ui/components/donation-status-actions.tsx create mode 100644 ui/components/invoice-panel.tsx create mode 100644 ui/components/invoice-payment-details.tsx create mode 100644 ui/components/invoice-status-actions.tsx create mode 100644 ui/components/invoice-status-auto-refresh.tsx create mode 100644 ui/components/invoice-status-lookup.tsx create mode 100644 ui/components/login-modal.tsx create mode 100644 ui/components/relative-time.ts create mode 100644 ui/components/secret-card.tsx create mode 100644 ui/components/status-refresh-button.tsx create mode 100644 ui/components/webhook-history-panel.tsx create mode 100644 ui/components/webhook-secret-section.tsx create mode 100644 ui/components/webhook-section.tsx create mode 100644 ui/lib/formatting.ts create mode 100644 ui/lib/use-xmr-usd-rate.ts create mode 100644 ui/next-env.d.ts create mode 100644 ui/next.config.mjs create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/postcss.config.cjs create mode 100644 ui/public/logo.png create mode 100644 ui/public/modal/btcpay.js create mode 100644 ui/public/monero-logo.svg create mode 100644 ui/tailwind.config.ts create mode 100644 ui/tsconfig.json diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..4f35e97 --- /dev/null +++ b/ui/.env.example @@ -0,0 +1,4 @@ +API_BASE_URL=http://localhost:8000 +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +NEXT_PUBLIC_SITE_URL=http://localhost +NEXT_PUBLIC_DONATIONS_ENABLED=false diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 0000000..76e1389 --- /dev/null +++ b/ui/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ["next/core-web-vitals"], +}; diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..089bb08 --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/ui/app/(app)/dashboard/actions.ts b/ui/app/(app)/dashboard/actions.ts new file mode 100644 index 0000000..ee6586a --- /dev/null +++ b/ui/app/(app)/dashboard/actions.ts @@ -0,0 +1,707 @@ +"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 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 { + const instantConfirmation = formData.get("instant_confirmation") === "1"; + const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim(); + const confirmationTarget = instantConfirmation ? 0 : Number(confirmationRaw); + if ( + Number.isNaN(confirmationTarget) || + confirmationTarget < 0 || + confirmationTarget > 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, + }; + } + const amount = Number(formData.get("amount_xmr")); + if (Number.isNaN(amount) || amount <= 0) { + return { + error: "Enter a valid XMR amount.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + const amountRaw = String(formData.get("amount_xmr") ?? ""); + const recipientName = String(formData.get("recipient_name") ?? "").trim(); + const description = String(formData.get("description") ?? "").trim(); + 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("xmrcheckout_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 = {}; + 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: amount, + confirmation_target: confirmationTarget, + expires_at: expiresAt, + 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; + warnings?: string[] | null; + }; + return { + error: null, + invoiceId: data.id, + address: data.address, + amount: amountRaw, + recipientName: recipientName || null, + description: description || null, + subaddressIndex: data.subaddress_index ?? null, + warnings: data.warnings ?? null, + }; +} + +export async function resetApiKeyAction( + _prevState: ApiKeyState +): Promise { + const apiKey = (await cookies()).get("xmrcheckout_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("xmrcheckout_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 { + const apiKey = (await cookies()).get("xmrcheckout_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("xmrcheckout_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 { + 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("xmrcheckout_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 { + 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("xmrcheckout_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 { + 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("xmrcheckout_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 { + const apiKey = (await cookies()).get("xmrcheckout_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 = {}; + + 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; + } = { + 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 { + const apiKey = (await cookies()).get("xmrcheckout_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 { + const apiKey = (await cookies()).get("xmrcheckout_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("xmrcheckout_api_key", "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + cookieStore.set("xmrcheckout_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 { + const apiKey = (await cookies()).get("xmrcheckout_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." }; +} diff --git a/ui/app/(app)/dashboard/invoices.csv/route.ts b/ui/app/(app)/dashboard/invoices.csv/route.ts new file mode 100644 index 0000000..a7f3a3c --- /dev/null +++ b/ui/app/(app)/dashboard/invoices.csv/route.ts @@ -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("xmrcheckout_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 }); +} + diff --git a/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..1b2a380 --- /dev/null +++ b/ui/app/(app)/dashboard/page.tsx @@ -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; +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 | 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; +}) { + const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value; + const webhookSecret = (await cookies()).get("xmrcheckout_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 ( +
+
+ +
+ {activeTab === "overview" ? ( +
+

Operational overview.

+

+ Track invoice flow and confirmation progress at a glance. +

+
+

+ Primary address +

+

+ {profileData?.payment_address ?? "Unavailable"} +

+

+ This is the wallet address used for your invoices. +

+
+
+ {[ + { + 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) => ( +
+

+ {item.title} +

+

{item.value}

+

{item.detail}

+
+ ))} +
+
+

+ Webhook endpoints +

+ {webhooksData.length === 0 ? ( +

+ No webhooks configured yet.{" "} + + Add a webhook endpoint. + +

+ ) : ( +
+ {webhooksData.map((webhook) => ( +
+
+

{webhook.url}

+ + {webhook.active ? "Active" : "Paused"} + +
+

+ Events: {webhook.events.join(", ")} +

+
+ ))} +
+ )} +
+
+ We never hold funds. All payments move from the customer to your + wallet. +
+
+ ) : null} + {activeTab === "invoices" ? ( + + ) : null} + {activeTab === "profile" ? ( + <> + {apiKeyData ? ( +
+
+
+

Profile settings.

+

+ Manage credentials and delivery settings for your integration. +

+
+
+
+
+

Store id

+

+ Single-store per primary address. Use this id in the BTCPay + WooCommerce plugin store field. +

+
+

+ {profileData?.id ?? "Unavailable"} +

+
+
+
+

API key

+

+ Use this key with authenticated endpoints. +

+
+ +
+
+
+

Webhook secret

+

+ Sent with each delivery as the{" "} + X-Webhook-Secret header. +

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ ) : ( +
+

API key

+

+ Sign in again to reveal your API key. +

+
+ )} + + ) : null} + {activeTab === "webhooks" ? ( +
+
+
+

Webhook endpoints.

+

+ Relay invoice state updates to your systems. Optional overrides let + you target different URLs per event. +

+
+
+ + {activeWebhookTab === "settings" ? ( +
+ +
+ ) : null} + {activeWebhookTab === "history" ? ( +
+ +
+ ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/ui/app/(app)/layout.tsx b/ui/app/(app)/layout.tsx new file mode 100644 index 0000000..9458fa8 --- /dev/null +++ b/ui/app/(app)/layout.tsx @@ -0,0 +1,61 @@ +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 { logoutAction } from "./dashboard/actions"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+ + XMR Checkout logo + + + XMR Checkout + +
+ +
+ {children} +
+
+

xmrcheckout.com - Non-custodial Monero checkout software.

+

+ + GitHub + +

+
+ + + +
+
+ ); +} diff --git a/ui/app/(app)/login/actions.ts b/ui/app/(app)/login/actions.ts new file mode 100644 index 0000000..371a727 --- /dev/null +++ b/ui/app/(app)/login/actions.ts @@ -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 { + 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("xmrcheckout_api_key", data.api_key, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + cookieStore.set("xmrcheckout_webhook_secret", data.webhook_secret, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + + redirect("/dashboard"); +} diff --git a/ui/app/(app)/login/login-form.tsx b/ui/app/(app)/login/login-form.tsx new file mode 100644 index 0000000..02bb438 --- /dev/null +++ b/ui/app/(app)/login/login-form.tsx @@ -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(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 ( +
+
+
+ + {isLikelyPrimaryAddress(paymentAddress) ? ( + + + Valid + + ) : null} +
+