Add OpenPayd marketplace admin integration
Some checks failed
API CI / API Lint (push) Successful in 38s
API CI / API Type Check (push) Failing after 43s
API CI / API Test (push) Successful in 56s
API CI / API Build (push) Failing after 45s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 23s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 1m21s
CI Pipeline / Test Frontend (push) Failing after 32s
CI Pipeline / Security Scan (push) Failing after 1m19s
Deploy to Staging / Deploy to Staging (push) Failing after 27s
Portal CI / Portal Lint (push) Failing after 23s
Portal CI / Portal Type Check (push) Failing after 20s
Portal CI / Portal Test (push) Failing after 23s
Portal CI / Portal Build (push) Failing after 23s
Test Suite / frontend-tests (push) Failing after 31s
Test Suite / api-tests (push) Failing after 45s
Test Suite / blockchain-tests (push) Failing after 31s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 19s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 21s
CD Pipeline / Deploy to Production (push) Has been skipped
Some checks failed
API CI / API Lint (push) Successful in 38s
API CI / API Type Check (push) Failing after 43s
API CI / API Test (push) Successful in 56s
API CI / API Build (push) Failing after 45s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 23s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 1m21s
CI Pipeline / Test Frontend (push) Failing after 32s
CI Pipeline / Security Scan (push) Failing after 1m19s
Deploy to Staging / Deploy to Staging (push) Failing after 27s
Portal CI / Portal Lint (push) Failing after 23s
Portal CI / Portal Type Check (push) Failing after 20s
Portal CI / Portal Test (push) Failing after 23s
Portal CI / Portal Build (push) Failing after 23s
Test Suite / frontend-tests (push) Failing after 31s
Test Suite / api-tests (push) Failing after 45s
Test Suite / blockchain-tests (push) Failing after 31s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 19s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 21s
CD Pipeline / Deploy to Production (push) Has been skipped
This commit is contained in:
114
api/src/routes/openpayd-connector.ts
Normal file
114
api/src/routes/openpayd-connector.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import { requireTenant } from '../middleware/tenant-auth.js'
|
||||||
|
import { marketplaceSubscriptionService } from '../services/marketplace-subscription.js'
|
||||||
|
|
||||||
|
const PRODUCT_SLUG = 'phoenix-openpayd-connector'
|
||||||
|
const ENTITLEMENT_KEY = 'OPENPAYD_CONNECTOR_ENTITLED'
|
||||||
|
const CONNECTOR_BASE =
|
||||||
|
process.env.OPENPAYD_CONNECTOR_INTERNAL_URL || 'https://openpayd-connector.sankofa.nexus'
|
||||||
|
|
||||||
|
const OPERATOR_COMMANDS = new Set([
|
||||||
|
'probeEnvironment',
|
||||||
|
'rotateCredential',
|
||||||
|
'setProductionMutatingGate',
|
||||||
|
'suspendTenant',
|
||||||
|
'offboardTenant',
|
||||||
|
'exportComplianceEvidencePack'
|
||||||
|
])
|
||||||
|
|
||||||
|
const TENANT_COMMANDS = new Set([
|
||||||
|
'getStatus',
|
||||||
|
'syncPaymentAccounts',
|
||||||
|
'getBeneficiaryRequiredDetails',
|
||||||
|
'validateBeneficiary',
|
||||||
|
'createLinkedClient',
|
||||||
|
'createAccount',
|
||||||
|
'createBankBeneficiary',
|
||||||
|
'createPayout',
|
||||||
|
'initiateOpenBankingPayment'
|
||||||
|
])
|
||||||
|
|
||||||
|
function tenantFromRequest(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const headerTenant = (request.headers['x-tenant-id'] as string | undefined)?.trim()
|
||||||
|
if (headerTenant && request.tenantContext) {
|
||||||
|
request.tenantContext = { ...request.tenantContext, tenantId: headerTenant }
|
||||||
|
}
|
||||||
|
return requireTenant(request, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOperator(context: ReturnType<typeof tenantFromRequest>): boolean {
|
||||||
|
const role = `${context.role || ''}`.toLowerCase()
|
||||||
|
const tenantRole = `${context.tenantRole || ''}`.toLowerCase()
|
||||||
|
return context.isSystemAdmin || role === 'admin' || tenantRole === 'tenant-admin' || tenantRole === 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entitlementState(tenantId: string) {
|
||||||
|
const entitlements = await marketplaceSubscriptionService.getTenantEntitlements(tenantId)
|
||||||
|
const row = entitlements.find((entitlement) => entitlement.entitlementKey === ENTITLEMENT_KEY)
|
||||||
|
return {
|
||||||
|
entitlement: row,
|
||||||
|
entitled: Boolean(row && ['ACTIVE', 'PENDING', 'REQUEST_ONLY'].includes(row.status)),
|
||||||
|
active: row?.status === 'ACTIVE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyConnector(command: string, body: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`${CONNECTOR_BASE.replace(/\/$/, '')}/v1/commands/${command}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
return { status: res.status, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerOpenPaydConnectorRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.get('/api/v1/openpayd/status', async (request, reply) => {
|
||||||
|
const context = tenantFromRequest(request, reply)
|
||||||
|
const tenantId = context.tenantId!
|
||||||
|
const entitlements = await entitlementState(tenantId)
|
||||||
|
if (!entitlements.entitled && !isOperator(context)) {
|
||||||
|
return reply.code(403).send({ error: 'OpenPayd connector entitlement required', productSlug: PRODUCT_SLUG })
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = ((request.query as { environment?: string }).environment || 'sandbox') as string
|
||||||
|
const proxied = await proxyConnector('getStatus', { tenantId, environment })
|
||||||
|
return reply.code(proxied.status).send({
|
||||||
|
productSlug: PRODUCT_SLUG,
|
||||||
|
entitlementKey: ENTITLEMENT_KEY,
|
||||||
|
entitlementStatus: entitlements.entitlement?.status || 'NONE',
|
||||||
|
connector: proxied.data
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/v1/openpayd/commands/:command', async (request, reply) => {
|
||||||
|
const context = tenantFromRequest(request, reply)
|
||||||
|
const tenantId = context.tenantId!
|
||||||
|
const command = (request.params as { command: string }).command
|
||||||
|
const body = (request.body || {}) as Record<string, unknown>
|
||||||
|
const entitlements = await entitlementState(tenantId)
|
||||||
|
const operator = isOperator(context)
|
||||||
|
|
||||||
|
if (!entitlements.entitled && !operator) {
|
||||||
|
return reply.code(403).send({ error: 'OpenPayd connector entitlement required', productSlug: PRODUCT_SLUG })
|
||||||
|
}
|
||||||
|
if (OPERATOR_COMMANDS.has(command) && !operator) {
|
||||||
|
return reply.code(403).send({ error: 'Operator role required for OpenPayd command', command })
|
||||||
|
}
|
||||||
|
if (!OPERATOR_COMMANDS.has(command) && !TENANT_COMMANDS.has(command)) {
|
||||||
|
return reply.code(404).send({ error: 'Unsupported OpenPayd command', command })
|
||||||
|
}
|
||||||
|
if (!operator && !entitlements.active && command !== 'getStatus') {
|
||||||
|
return reply.code(403).send({ error: 'Active OpenPayd entitlement required', command })
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxied = await proxyConnector(command, {
|
||||||
|
tenantId,
|
||||||
|
environment: body.environment || 'sandbox',
|
||||||
|
payload: body.payload || {},
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
approvalRef: body.approvalRef
|
||||||
|
})
|
||||||
|
return reply.code(proxied.status).send(proxied.data)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { validateAllSecrets } from './lib/secret-validation'
|
|||||||
import { initializeFIPS } from './lib/crypto'
|
import { initializeFIPS } from './lib/crypto'
|
||||||
import { getFastifyTLSOptions } from './lib/tls-config'
|
import { getFastifyTLSOptions } from './lib/tls-config'
|
||||||
import { registerPhoenixRailingRoutes } from './routes/phoenix-railing.js'
|
import { registerPhoenixRailingRoutes } from './routes/phoenix-railing.js'
|
||||||
|
import { registerOpenPaydConnectorRoutes } from './routes/openpayd-connector.js'
|
||||||
import { printSchema } from 'graphql'
|
import { printSchema } from 'graphql'
|
||||||
|
|
||||||
// Get TLS configuration (empty if certificates not available)
|
// Get TLS configuration (empty if certificates not available)
|
||||||
@@ -98,11 +99,12 @@ async function startServer() {
|
|||||||
await apolloServer.start()
|
await apolloServer.start()
|
||||||
|
|
||||||
// Register GraphQL route
|
// Register GraphQL route
|
||||||
fastify.post('/graphql', async (request, reply) => {
|
fastify.post(
|
||||||
return fastifyApolloHandler(apolloServer, {
|
'/graphql',
|
||||||
context: async () => createContext(request),
|
fastifyApolloHandler(apolloServer, {
|
||||||
})(request, reply)
|
context: async (request) => createContext(request),
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
@@ -141,6 +143,7 @@ async function startServer() {
|
|||||||
|
|
||||||
// Phoenix API Railing: /api/v1/infra/*, /api/v1/ve/*, /api/v1/health/* proxy + /api/v1/tenants/me/*
|
// Phoenix API Railing: /api/v1/infra/*, /api/v1/ve/*, /api/v1/health/* proxy + /api/v1/tenants/me/*
|
||||||
await registerPhoenixRailingRoutes(fastify)
|
await registerPhoenixRailingRoutes(fastify)
|
||||||
|
await registerOpenPaydConnectorRoutes(fastify)
|
||||||
|
|
||||||
// Start Fastify server
|
// Start Fastify server
|
||||||
const port = parseInt(process.env.PORT || '4000', 10)
|
const port = parseInt(process.env.PORT || '4000', 10)
|
||||||
@@ -161,4 +164,3 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startServer()
|
startServer()
|
||||||
|
|
||||||
|
|||||||
149
portal/src/app/admin/openpayd/page.tsx
Normal file
149
portal/src/app/admin/openpayd/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Download, KeyRound, PauseCircle, PlayCircle, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { RoleGate } from '@/components/auth/RoleGate';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
|
|
||||||
|
type Environment = 'sandbox' | 'production';
|
||||||
|
|
||||||
|
export default function OpenPaydAdminPage() {
|
||||||
|
return (
|
||||||
|
<RoleGate
|
||||||
|
allowedRoles={['admin', 'tenant-admin', 'ADMIN', 'TENANT_ADMIN']}
|
||||||
|
callbackUrl="/admin/openpayd"
|
||||||
|
badge="OpenPayd Admin"
|
||||||
|
title="OpenPayd operator access required"
|
||||||
|
subtitle="Sign in with a tenant-admin or internal-admin account to manage the OpenPayd connector."
|
||||||
|
>
|
||||||
|
<OpenPaydAdminWorkbench />
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenPaydAdminWorkbench() {
|
||||||
|
const [environment, setEnvironment] = useState<Environment>('sandbox');
|
||||||
|
const [status, setStatus] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/openpayd/status?environment=${environment}`, { cache: 'no-store' });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!res.ok) throw new Error(payload.error || payload.message || 'Status load failed');
|
||||||
|
setStatus(payload);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Status load failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function command(name: string, payload: Record<string, unknown> = {}) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/openpayd/commands/${name}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ environment, payload }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.status || data.error || data.message || `${name} failed`);
|
||||||
|
setMessage(`${name}: ${data.status || 'ok'}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : `${name} failed`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [environment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-10 text-white">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium uppercase tracking-wide text-orange-400">Operator Console</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-bold">OpenPayd Connector Admin</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-gray-400">
|
||||||
|
Manage environment probes, lifecycle state, credential rotation workflow, evidence exports, and
|
||||||
|
production gates without exposing raw OpenPayd credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={environment === 'sandbox' ? 'primary' : 'outline'} onClick={() => setEnvironment('sandbox')}>
|
||||||
|
Sandbox
|
||||||
|
</Button>
|
||||||
|
<Button variant={environment === 'production' ? 'primary' : 'outline'} onClick={() => setEnvironment('production')}>
|
||||||
|
Production
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={load} disabled={loading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <div className="mb-4 rounded-md border border-emerald-800 bg-emerald-950/40 p-3 text-sm text-emerald-200">{message}</div> : null}
|
||||||
|
{error ? <div className="mb-4 rounded-md border border-red-800 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ActionCard icon={PlayCircle} title="Run Probe" text="Token and read-only account probe." action={() => command('probeEnvironment')} disabled={loading} />
|
||||||
|
<ActionCard icon={KeyRound} title="Rotate Credential" text="Record a credential rotation through secret adapter." action={() => command('rotateCredential')} disabled={loading} />
|
||||||
|
<ActionCard icon={ShieldCheck} title="Production Gate" text="Enable production mutating traffic for approved tenants." action={() => command('setProductionMutatingGate', { enabled: true })} disabled={loading || environment !== 'production'} />
|
||||||
|
<ActionCard icon={PauseCircle} title="Suspend Tenant" text="Block new mutating actions and keep read-only reconciliation." action={() => command('suspendTenant')} disabled={loading} />
|
||||||
|
<ActionCard icon={Download} title="Export Evidence" text="Generate redacted compliance evidence pack." action={() => command('exportComplianceEvidencePack')} disabled={loading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-6 border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-orange-400" /> Connector State
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="max-h-[36rem] overflow-auto rounded-md bg-black/30 p-3 text-xs text-gray-300">
|
||||||
|
{JSON.stringify(status || {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
action,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: typeof PlayCircle;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
action: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<Icon className="h-6 w-6 text-orange-400" />
|
||||||
|
<CardTitle className="text-white">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-4 text-sm text-gray-400">{text}</p>
|
||||||
|
<Button variant="secondary" onClick={action} disabled={disabled}>Execute</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Building2, Users, CreditCard, Shield, ArrowRight } from 'lucide-react';
|
import { Building2, Users, CreditCard, Shield, ArrowRight, Landmark } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@@ -39,6 +39,13 @@ export default function AdminPortalPage() {
|
|||||||
href: '/admin/compliance',
|
href: '/admin/compliance',
|
||||||
features: ['Compliance dashboard', 'Audit logs', 'Export reports'],
|
features: ['Compliance dashboard', 'Audit logs', 'Export reports'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'OpenPayd Connector',
|
||||||
|
description: 'Manage OpenPayd credentials, probes, lifecycle gates, evidence packs, and incidents',
|
||||||
|
icon: Landmark,
|
||||||
|
href: '/admin/openpayd',
|
||||||
|
features: ['Sandbox probes', 'Production gates', 'Evidence export'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
37
portal/src/app/api/openpayd/commands/[command]/route.ts
Normal file
37
portal/src/app/api/openpayd/commands/[command]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.PHOENIX_API_INTERNAL_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_PHOENIX_API_URL ||
|
||||||
|
'https://api.sankofa.nexus';
|
||||||
|
|
||||||
|
type SessionClaims = {
|
||||||
|
accessToken?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: { params: { command: string } }) {
|
||||||
|
const session = (await getServerSession(authOptions)) as SessionClaims;
|
||||||
|
if (!session?.accessToken) {
|
||||||
|
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (session.tenantId) headers['X-Tenant-Id'] = session.tenantId;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/openpayd/commands/${params.command}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
35
portal/src/app/api/openpayd/status/route.ts
Normal file
35
portal/src/app/api/openpayd/status/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.PHOENIX_API_INTERNAL_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_PHOENIX_API_URL ||
|
||||||
|
'https://api.sankofa.nexus';
|
||||||
|
|
||||||
|
type SessionClaims = {
|
||||||
|
accessToken?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = (await getServerSession(authOptions)) as SessionClaims;
|
||||||
|
if (!session?.accessToken) {
|
||||||
|
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const environment = url.searchParams.get('environment') || 'sandbox';
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
};
|
||||||
|
if (session.tenantId) headers['X-Tenant-Id'] = session.tenantId;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/openpayd/status?environment=${encodeURIComponent(environment)}`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { RefreshCw, ShieldAlert } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
type ConnectorStatus = {
|
||||||
|
entitlementStatus?: string;
|
||||||
|
connector?: {
|
||||||
|
ok?: boolean;
|
||||||
|
status?: string;
|
||||||
|
data?: {
|
||||||
|
tenantEnvironment?: Record<string, unknown>;
|
||||||
|
recentCommands?: Record<string, unknown>[];
|
||||||
|
recentWebhookEvents?: Record<string, unknown>[];
|
||||||
|
reconciliationExceptions?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OpenPaydTenantDashboardPage() {
|
||||||
|
const [environment, setEnvironment] = useState<'sandbox' | 'production'>('sandbox');
|
||||||
|
const [data, setData] = useState<ConnectorStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/openpayd/status?environment=${environment}`, { cache: 'no-store' });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!res.ok) throw new Error(payload.error || payload.message || 'OpenPayd status failed');
|
||||||
|
setData(payload);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'OpenPayd status failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [environment]);
|
||||||
|
|
||||||
|
const status = data?.connector?.data?.tenantEnvironment || {};
|
||||||
|
const commands = data?.connector?.data?.recentCommands || [];
|
||||||
|
const events = data?.connector?.data?.recentWebhookEvents || [];
|
||||||
|
const exceptions = data?.connector?.data?.reconciliationExceptions || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-10 text-white">
|
||||||
|
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium uppercase tracking-wide text-orange-400">OpenPayd Connector</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-bold">Tenant Dashboard</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-gray-400">
|
||||||
|
Read-only operational view for entitlement, lifecycle, environment health, commands, webhook facts,
|
||||||
|
and reconciliation exceptions. Sensitive account and credential values are not rendered here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={environment === 'sandbox' ? 'primary' : 'outline'} onClick={() => setEnvironment('sandbox')}>
|
||||||
|
Sandbox
|
||||||
|
</Button>
|
||||||
|
<Button variant={environment === 'production' ? 'primary' : 'outline'} onClick={() => setEnvironment('production')}>
|
||||||
|
Production
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={load} disabled={loading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card className="mb-6 border-red-800 bg-red-950/40">
|
||||||
|
<CardContent className="pt-6 text-red-200">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card className="border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader><CardTitle className="text-white">Entitlement</CardTitle></CardHeader>
|
||||||
|
<CardContent className="text-sm text-gray-300">{data?.entitlementStatus || 'Loading'}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader><CardTitle className="text-white">Lifecycle</CardTitle></CardHeader>
|
||||||
|
<CardContent className="text-sm text-gray-300">{String(status.state || 'unknown')}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader><CardTitle className="text-white">Mutating Gate</CardTitle></CardHeader>
|
||||||
|
<CardContent className="text-sm text-gray-300">{String(status.mutatingActionsEnabled || false)}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||||
|
<RecordList title="Recent Commands" rows={commands} />
|
||||||
|
<RecordList title="Webhook Facts" rows={events} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-6 border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<ShieldAlert className="h-5 w-5 text-orange-400" /> Reconciliation Exceptions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{exceptions.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No reconciliation exceptions reported.</p>
|
||||||
|
) : (
|
||||||
|
<pre className="overflow-x-auto rounded-md bg-black/30 p-3 text-xs text-gray-300">
|
||||||
|
{JSON.stringify(exceptions, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordList({ title, rows }: { title: string; rows: Record<string, unknown>[] }) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-800 bg-gray-900">
|
||||||
|
<CardHeader><CardTitle className="text-white">{title}</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No records yet.</p>
|
||||||
|
) : (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md bg-black/30 p-3 text-xs text-gray-300">
|
||||||
|
{JSON.stringify(rows, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
portal/src/app/marketplace/openpayd/page.tsx
Normal file
84
portal/src/app/marketplace/openpayd/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ArrowRight, Landmark, LockKeyhole, ShieldCheck } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { CorporateFooter } from '@/components/corporate/CorporateFooter';
|
||||||
|
import { CorporateHeader } from '@/components/corporate/CorporateHeader';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'OpenPayd Financial Services Connector — Sankofa Marketplace',
|
||||||
|
description: 'Operator-provisioned OpenPayd connector for Phoenix Financial Cloud subscribers.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const skus = [
|
||||||
|
'openpayd-connector-sandbox',
|
||||||
|
'openpayd-connector-production-readonly',
|
||||||
|
'openpayd-connector-production-payouts',
|
||||||
|
'openpayd-connector-digital-assets-gated',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OpenPaydMarketplacePage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-sovereign-obsidian text-sovereign-ivory">
|
||||||
|
<CorporateHeader />
|
||||||
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-sovereign-gold">
|
||||||
|
Phoenix Financial Cloud
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
OpenPayd Financial Services Connector
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-3xl text-lg text-sovereign-ivory/70">
|
||||||
|
Operator-provisioned access to OpenPayd-backed accounts, virtual IBANs, beneficiary validation,
|
||||||
|
payouts, Open Banking, and gated digital-asset capabilities through Phoenix controls.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ icon: Landmark, title: 'Financial rails', text: 'Accounts, payment accounts, beneficiaries, payouts, and Open Banking behind tenant policy.' },
|
||||||
|
{ icon: ShieldCheck, title: 'Phoenix gated', text: 'Entitlements, approval gates, audit records, and reconciliation boundaries stay in Phoenix.' },
|
||||||
|
{ icon: LockKeyhole, title: 'Operator provisioned', text: 'Credentials and production mutating traffic are controlled by authorized operators only.' },
|
||||||
|
].map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<article key={item.title} className="rounded-lg border border-sovereign-bronze/25 bg-sovereign-midnight/40 p-5">
|
||||||
|
<Icon className="h-6 w-6 text-sovereign-gold" />
|
||||||
|
<h2 className="mt-3 text-lg font-semibold">{item.title}</h2>
|
||||||
|
<p className="mt-2 text-sm text-sovereign-ivory/60">{item.text}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-10 rounded-lg border border-sovereign-bronze/25 bg-sovereign-midnight/30 p-6">
|
||||||
|
<h2 className="text-xl font-semibold">Catalog SKUs</h2>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{skus.map((sku) => (
|
||||||
|
<div key={sku} className="rounded-md border border-white/10 bg-black/20 p-3 font-mono text-sm text-sovereign-gold">
|
||||||
|
{sku}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-sovereign-ivory/65">
|
||||||
|
Production URLs, production credentials, webhook keys, and digital-asset contracts remain gated
|
||||||
|
OpenPayd onboarding inputs. Production mutating actions are disabled by default.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link
|
||||||
|
href="/marketplace/entitlements/phoenix-openpayd-connector"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-sovereign-gold px-5 py-2.5 text-sm font-semibold text-sovereign-obsidian no-underline hover:bg-sovereign-ivory"
|
||||||
|
>
|
||||||
|
Request subscription <ArrowRight className="h-4 w-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/marketplace/entitlements/phoenix-openpayd-connector/dashboard"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-sovereign-bronze/40 px-5 py-2.5 text-sm font-semibold text-sovereign-ivory no-underline hover:border-sovereign-gold"
|
||||||
|
>
|
||||||
|
Open dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<CorporateFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -140,13 +140,23 @@ export const productDivisions: ProductDivision[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Marketplace partner / product stack entries derived from product divisions. */
|
/** Marketplace partner / product stack entries derived from product divisions. */
|
||||||
export const partnerStackServices: PartnerStackService[] = productDivisions.map((division) => ({
|
export const partnerStackServices: PartnerStackService[] = [
|
||||||
name: division.name,
|
...productDivisions.map((division) => ({
|
||||||
href: division.href,
|
name: division.name,
|
||||||
description: division.description,
|
href: division.href,
|
||||||
category: division.tagline,
|
description: division.description,
|
||||||
external: division.external,
|
category: division.tagline,
|
||||||
}));
|
external: division.external,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'OpenPayd Financial Services Connector',
|
||||||
|
href: '/marketplace/openpayd',
|
||||||
|
description:
|
||||||
|
'Operator-provisioned OpenPayd accounts, virtual IBANs, payouts, Open Banking, and gated digital-asset rails for Phoenix Financial Cloud subscribers.',
|
||||||
|
serviceUrl: 'https://openpayd-connector.sankofa.nexus',
|
||||||
|
category: 'Financial messaging',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const platformServices: ServiceOffering[] = [
|
export const platformServices: ServiceOffering[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user