Compare commits

..

1 Commits

Author SHA1 Message Date
e5f9da6c26 feat(ui): add sortable column headers to domains table 2026-03-06 06:58:29 +00:00
22 changed files with 289 additions and 595 deletions

View File

@@ -1,13 +0,0 @@
node_modules
.next
.git
*.log
logs
.env
.env.*
!.env.example
.env.local
.env.*.local
.vscode
.idea
.DS_Store

View File

@@ -1,22 +1,14 @@
NODE_ENV=production
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
PLESK_CRED_ENC_KEY= PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
CRON_SHARED_SECRET=
# Existing runtime keys used by current codebase
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
STRIPE_PRICE_ID= STRIPE_PRICE_ID=

View File

@@ -1,6 +1,3 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript"], "extends": ["next/core-web-vitals", "next/typescript"]
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
} }

View File

@@ -1,37 +0,0 @@
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/.next ./.next
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

134
Jenkinsfile vendored
View File

@@ -1,134 +0,0 @@
pipeline {
// Build + deploy stages require Docker CLI/daemon access on the Jenkins node.
// Ensure this label maps to an agent with Docker installed and usable.
//update
agent any
environment {
REGISTRY_IMAGE = "plesk-agency-portal"
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}"
DEPLOY_SSH_CREDENTIAL_ID = "${env.DEPLOY_SSH_CREDENTIAL_ID ?: 'plesk-agency-deploy-key'}"
APP_HOST = "${env.APP_HOST ?: '192.168.68.89'}"
APP_USER = "${env.APP_USER ?: 'deploy'}"
DEPLOY_PATH = "${env.DEPLOY_PATH ?: '/opt/plesk-agency-portal'}"
APP_BASE_URL = "${env.APP_BASE_URL ?: 'http://192.168.68.89:3000'}"
}
stages {
// stage('Preflight') {
// steps {
// sh '''
// set -euo pipefail
// if ! command -v docker >/dev/null 2>&1; then
// echo "Docker CLI is not available on this Jenkins agent."
// echo "Run this pipeline on a Docker-capable node or install Docker on the current agent."
// exit 1
// fi
// if ! docker version >/dev/null 2>&1; then
// echo "Docker is installed but not usable by Jenkins (daemon/socket/permissions issue)."
// exit 1
// fi
// '''
// }
// }
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install dependencies') {
steps {
sh 'npm ci'
}
}
// stage('Lint') {
// steps {
// script {
// def hasLint = sh(script: "node -e \"const fs=require('fs');const p=require('./package.json');process.exit(p?.scripts?.lint?0:1)\"", returnStatus: true)
// if (hasLint == 0) {
// sh 'npm run lint'
// } else {
// echo 'No lint script configured; skipping lint stage.'
// }
// }
// }
// }
stage('Build application') {
steps {
withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'NEXT_PUBLIC_SUPABASE_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh 'npm run build'
}
}
}
stage('Build Docker image') {
steps {
withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'NEXT_PUBLIC_SUPABASE_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh 'docker build --build-arg NEXT_PUBLIC_SUPABASE_URL="$NEXT_PUBLIC_SUPABASE_URL" --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="$NEXT_PUBLIC_SUPABASE_ANON_KEY" -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
}
}
}
stage('Deploy') {
steps {
withCredentials([sshUserPrivateKey(
credentialsId: "${env.DEPLOY_SSH_CREDENTIAL_ID}",
keyFileVariable: 'SSH_KEY'
)]) {
sh '''
bash -eo pipefail << 'EOF'
set -euo pipefail
ssh -i "$SSH_KEY" "$APP_USER@$APP_HOST" "mkdir -p $DEPLOY_PATH/scripts"
rsync -az \
-e "ssh -i $SSH_KEY" \
docker-compose.prod.yml \
"$APP_USER@$APP_HOST:$DEPLOY_PATH/"
rsync -az \
-e "ssh -i $SSH_KEY" \
scripts/ \
"$APP_USER@$APP_HOST:$DEPLOY_PATH/scripts/"
docker save ${IMAGE_LATEST} ${IMAGE_BUILD} \
| ssh -i "$SSH_KEY" "$APP_USER@$APP_HOST" 'docker load'
ssh -i "$SSH_KEY" "$APP_USER@$APP_HOST" \
"cd $DEPLOY_PATH && chmod +x ./scripts/deploy-prod.sh ./scripts/smoke-check.sh ./scripts/rollback-prod.sh"
ssh -i "$SSH_KEY" "$APP_USER@$APP_HOST" \
"cd $DEPLOY_PATH && ./scripts/deploy-prod.sh"
EOF
'''
}
}
}
stage('Smoke check') {
steps {
sh '''
bash -eo pipefail << EOF
set -euo pipefail
chmod +x scripts/smoke-check.sh
scripts/smoke-check.sh "${APP_BASE_URL}"
EOF
'''
}
}
}
}

View File

@@ -9,7 +9,7 @@ export async function POST(req: Request) {
try { try {
const secret = req.headers.get("x-cron-secret"); const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) { if (!env.cronSecret || !secret || secret !== env.cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }

View File

@@ -9,7 +9,7 @@ export async function POST(req: Request) {
try { try {
const secret = req.headers.get("x-cron-secret"); const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) { if (!env.cronSecret || !secret || secret !== env.cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }

View File

@@ -1,13 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
ok: true,
service: "plesk-agency-portal",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV ?? "development",
},
{ status: 200 },
);
}

View File

@@ -15,7 +15,7 @@ export async function POST() {
} }
const stripe = getStripeClient(); const stripe = getStripeClient();
const supabase = createSupabaseRouteClient() as any; const supabase = createSupabaseRouteClient();
const { data: billing, error: billingError } = await supabase const { data: billing, error: billingError } = await supabase
.from("billing_accounts") .from("billing_accounts")
@@ -42,10 +42,7 @@ export async function POST() {
}; };
if (billing?.id) { if (billing?.id) {
await supabase await supabase.from("billing_accounts").update(upsertPayload).eq("id", billing.id);
.from("billing_accounts")
.update(upsertPayload)
.eq("id", billing.id);
} else { } else {
await supabase.from("billing_accounts").insert(upsertPayload); await supabase.from("billing_accounts").insert(upsertPayload);
} }
@@ -65,12 +62,7 @@ export async function POST() {
return NextResponse.json({ url: session.url }); return NextResponse.json({ url: session.url });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ { error: error instanceof Error ? error.message : "Failed to create checkout session" },
error:
error instanceof Error
? error.message
: "Failed to create checkout session",
},
{ status: 400 }, { status: 400 },
); );
} }

View File

@@ -10,7 +10,7 @@ export async function POST() {
const context = await getRouteAgencyContextOrThrow(); const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context); assertAgencyAdmin(context);
const supabase = createSupabaseRouteClient() as any; const supabase = createSupabaseRouteClient();
const stripe = getStripeClient(); const stripe = getStripeClient();
const { data: billing, error } = await supabase const { data: billing, error } = await supabase
@@ -32,12 +32,7 @@ export async function POST() {
return NextResponse.json({ url: session.url }); return NextResponse.json({ url: session.url });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ { error: error instanceof Error ? error.message : "Failed to open billing portal" },
error:
error instanceof Error
? error.message
: "Failed to open billing portal",
},
{ status: 400 }, { status: 400 },
); );
} }

View File

@@ -19,7 +19,7 @@ async function upsertBillingByCustomer(
current_period_end?: string | null; current_period_end?: string | null;
}, },
) { ) {
const supabase = createSupabaseAdminClient() as any; const supabase = createSupabaseAdminClient();
const { data: existing } = await supabase const { data: existing } = await supabase
.from("billing_accounts") .from("billing_accounts")
@@ -28,33 +28,22 @@ async function upsertBillingByCustomer(
.maybeSingle(); .maybeSingle();
if (existing?.id) { if (existing?.id) {
await supabase await supabase.from("billing_accounts").update(updates).eq("id", existing.id);
.from("billing_accounts")
.update(updates)
.eq("id", existing.id);
} }
} }
async function handleCheckoutSessionCompleted( async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
session: Stripe.Checkout.Session, const agencyId = typeof session.metadata?.agency_id === "string" ? session.metadata.agency_id : null;
) { const customerId = typeof session.customer === "string" ? session.customer : null;
const agencyId =
typeof session.metadata?.agency_id === "string"
? session.metadata.agency_id
: null;
const customerId =
typeof session.customer === "string" ? session.customer : null;
if (!agencyId || !customerId) return; if (!agencyId || !customerId) return;
const supabase = createSupabaseAdminClient() as any; const supabase = createSupabaseAdminClient();
const payload = { const payload = {
agency_id: agencyId, agency_id: agencyId,
stripe_customer_id: customerId, stripe_customer_id: customerId,
stripe_subscription_id: stripe_subscription_id:
typeof session.subscription === "string" typeof session.subscription === "string" ? (session.subscription as string) : null,
? (session.subscription as string)
: null,
subscription_status: "active", subscription_status: "active",
}; };
@@ -65,10 +54,7 @@ async function handleCheckoutSessionCompleted(
.maybeSingle(); .maybeSingle();
if (existing?.id) { if (existing?.id) {
await supabase await supabase.from("billing_accounts").update(payload).eq("id", existing.id);
.from("billing_accounts")
.update(payload)
.eq("id", existing.id);
} else { } else {
await supabase.from("billing_accounts").insert(payload); await supabase.from("billing_accounts").insert(payload);
} }
@@ -76,14 +62,7 @@ async function handleCheckoutSessionCompleted(
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId = const customerId =
typeof subscription.customer === "string" typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
? subscription.customer
: subscription.customer?.id;
const currentPeriodEnd =
"current_period_end" in subscription
? (subscription as { current_period_end?: number | null })
.current_period_end
: null;
if (!customerId) return; if (!customerId) return;
@@ -91,27 +70,20 @@ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
stripe_subscription_id: subscription.id, stripe_subscription_id: subscription.id,
plan_id: subscription.items.data[0]?.price.id ?? null, plan_id: subscription.items.data[0]?.price.id ?? null,
subscription_status: subscription.status, subscription_status: subscription.status,
current_period_end: toIsoOrNull(currentPeriodEnd), current_period_end: toIsoOrNull(subscription.current_period_end),
}); });
} }
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = const customerId =
typeof subscription.customer === "string" typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
? subscription.customer
: subscription.customer?.id;
const currentPeriodEnd =
"current_period_end" in subscription
? (subscription as { current_period_end?: number | null })
.current_period_end
: null;
if (!customerId) return; if (!customerId) return;
await upsertBillingByCustomer(customerId, { await upsertBillingByCustomer(customerId, {
stripe_subscription_id: subscription.id, stripe_subscription_id: subscription.id,
subscription_status: "canceled", subscription_status: "canceled",
current_period_end: toIsoOrNull(currentPeriodEnd), current_period_end: toIsoOrNull(subscription.current_period_end),
}); });
} }
@@ -123,35 +95,22 @@ export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature"); const signature = req.headers.get("stripe-signature");
if (!signature) { if (!signature) {
return NextResponse.json( return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
{ error: "Missing stripe-signature header" },
{ status: 400 },
);
} }
const stripe = getStripeClient(); const stripe = getStripeClient();
const body = await req.text(); const body = await req.text();
const event = stripe.webhooks.constructEvent( const event = stripe.webhooks.constructEvent(body, signature, env.stripeWebhookSecret);
body,
signature,
env.stripeWebhookSecret,
);
switch (event.type) { switch (event.type) {
case "checkout.session.completed": case "checkout.session.completed":
await handleCheckoutSessionCompleted( await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
event.data.object as Stripe.Checkout.Session,
);
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":
await handleSubscriptionUpdated( await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
event.data.object as Stripe.Subscription,
);
break; break;
case "customer.subscription.deleted": case "customer.subscription.deleted":
await handleSubscriptionDeleted( await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
event.data.object as Stripe.Subscription,
);
break; break;
default: default:
break; break;
@@ -160,10 +119,7 @@ export async function POST(req: Request) {
return NextResponse.json({ received: true }); return NextResponse.json({ received: true });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ { error: error instanceof Error ? error.message : "Webhook handling failed" },
error:
error instanceof Error ? error.message : "Webhook handling failed",
},
{ status: 400 }, { status: 400 },
); );
} }

View File

@@ -2,18 +2,12 @@ import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { env } from "@/lib/env";
import type { Database } from "@/lib/types"; import type { Database } from "@/lib/types";
export async function GET(request: Request) { export async function GET(request: Request) {
const requestUrl = new URL(request.url); const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code"); const code = requestUrl.searchParams.get("code");
const nextParam = requestUrl.searchParams.get("next"); const next = requestUrl.searchParams.get("next") ?? "/dashboard";
const next =
nextParam && nextParam.startsWith("/") && !nextParam.startsWith("//")
? nextParam
: "/dashboard";
const redirectOrigin = env.appOrigin || requestUrl.origin;
if (code) { if (code) {
const supabase = createRouteHandlerClient<Database>({ cookies }); const supabase = createRouteHandlerClient<Database>({ cookies });
@@ -21,16 +15,18 @@ export async function GET(request: Request) {
if (error) { if (error) {
console.error(error); console.error(error);
return NextResponse.redirect( return NextResponse.redirect(
new URL(`/login?authError=1`, redirectOrigin), new URL(`/login?authError=1`, requestUrl.origin),
); );
} }
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (!user) { if (!user) {
return NextResponse.redirect(new URL(`/login?noUser=1`, redirectOrigin)); return NextResponse.redirect(
new URL(`/login?noUser=1`, requestUrl.origin),
);
} }
} }
return NextResponse.redirect(new URL(next, redirectOrigin)); return NextResponse.redirect(new URL(next, requestUrl.origin));
} }

View File

@@ -4,8 +4,6 @@ import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServerClient } from "@/lib/supabase/server";
import { formatDateTime } from "@/lib/dates"; import { formatDateTime } from "@/lib/dates";
export const dynamic = "force-dynamic";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
type SearchParams = { type SearchParams = {
@@ -90,11 +88,9 @@ export default async function ActivityPage({
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(200); .limit(200);
const actionOptions: string[] = Array.from( const actionOptions = Array.from(
new Set<string>( new Set((recentForActions ?? []).map((entry: any) => String(entry.action))),
(recentForActions ?? []).map((entry: any) => String(entry.action)), ).sort();
),
).sort((a, b) => a.localeCompare(b));
const instanceNameById = new Map<string, string>( const instanceNameById = new Map<string, string>(
(instances ?? []).map((instance: any) => [ (instances ?? []).map((instance: any) => [

View File

@@ -6,8 +6,6 @@ import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServerClient } from "@/lib/supabase/server";
import Link from "next/link"; import Link from "next/link";
export const dynamic = "force-dynamic";
type AgencySummary = Pick< type AgencySummary = Pick<
Database["public"]["Tables"]["agencies"]["Row"], Database["public"]["Tables"]["agencies"]["Row"],
"id" | "name" "id" | "name"

View File

@@ -55,6 +55,15 @@ type Domain = {
updated_at: string; updated_at: string;
}; };
type DomainSortKey =
| "domain"
| "status"
| "hosting"
| "created"
| "aliases"
| "last_seen"
| "actions";
type Props = { type Props = {
instances: Instance[]; instances: Instance[];
subscriptions: Subscription[]; subscriptions: Subscription[];
@@ -198,6 +207,10 @@ export function DashboardControls({
); );
const [domainSearch, setDomainSearch] = useState(""); const [domainSearch, setDomainSearch] = useState("");
const [domainStatus, setDomainStatus] = useState("all"); const [domainStatus, setDomainStatus] = useState("all");
const [domainSort, setDomainSort] = useState<{
key: DomainSortKey;
direction: "asc" | "desc";
}>({ key: "domain", direction: "asc" });
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances); const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
@@ -295,6 +308,112 @@ export function DashboardControls({
return statusMatches && searchMatches; return statusMatches && searchMatches;
}); });
const sortedDomains = filteredDomains
.map((domain, index) => ({ domain, index }))
.sort((a, b) => {
function compareNullable<T>(
left: T | null | undefined,
right: T | null | undefined,
compare: (leftValue: T, rightValue: T) => number,
) {
const leftMissing = left == null;
const rightMissing = right == null;
if (leftMissing && rightMissing) return 0;
if (leftMissing) return 1;
if (rightMissing) return -1;
return compare(left, right);
}
function compareStrings(
left: string | null | undefined,
right: string | null | undefined,
) {
return compareNullable(left, right, (l, r) =>
l.localeCompare(r, undefined, { sensitivity: "base" }),
);
}
function compareNumbers(
left: number | null | undefined,
right: number | null | undefined,
) {
return compareNullable(left, right, (l, r) => l - r);
}
function toTime(value: string | null | undefined) {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isNaN(time) ? null : time;
}
const leftDomain = a.domain;
const rightDomain = b.domain;
let comparison = 0;
if (domainSort.key === "domain") {
comparison = compareStrings(
leftDomain.domain_name,
rightDomain.domain_name,
);
} else if (domainSort.key === "status") {
comparison = compareStrings(
formatDomainStatusLabel(leftDomain.status),
formatDomainStatusLabel(rightDomain.status),
);
} else if (domainSort.key === "hosting") {
comparison = compareStrings(
leftDomain.hosting_type,
rightDomain.hosting_type,
);
} else if (domainSort.key === "created") {
comparison = compareNumbers(
toTime(leftDomain.source_created_at),
toTime(rightDomain.source_created_at),
);
} else if (domainSort.key === "aliases") {
comparison = compareNumbers(
leftDomain.aliases_count,
rightDomain.aliases_count,
);
} else if (domainSort.key === "last_seen") {
comparison = compareNumbers(
toTime(leftDomain.updated_at),
toTime(rightDomain.updated_at),
);
} else if (domainSort.key === "actions") {
comparison = compareNumbers(
leftDomain.subscription_id ? 1 : 0,
rightDomain.subscription_id ? 1 : 0,
);
}
if (comparison === 0) {
comparison = a.index - b.index;
}
return domainSort.direction === "asc" ? comparison : comparison * -1;
})
.map((entry) => entry.domain);
function toggleDomainSort(key: DomainSortKey) {
setDomainSort((current) => {
if (current.key === key) {
return {
key,
direction: current.direction === "asc" ? "desc" : "asc",
};
}
return { key, direction: "asc" };
});
}
function getSortIndicator(key: DomainSortKey) {
if (domainSort.key !== key) return "↕";
return domainSort.direction === "asc" ? "↑" : "↓";
}
const instanceHealthSummary = instances.reduce( const instanceHealthSummary = instances.reduce(
(acc, instance) => { (acc, instance) => {
const status = (instance.health_status ?? "unknown").toLowerCase(); const status = (instance.health_status ?? "unknown").toLowerCase();
@@ -1094,24 +1213,143 @@ export function DashboardControls({
<table className="min-w-full text-left text-sm"> <table className="min-w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500"> <tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
<th className="px-2 py-2">Domain</th> <th className="px-2 py-2">
<th className="px-2 py-2">Subscription Status</th> <button
<th className="px-2 py-2">Hosting</th> type="button"
<th className="px-2 py-2">Created</th> onClick={() => toggleDomainSort("domain")}
<th className="px-2 py-2">Aliases</th> className="inline-flex items-center gap-1"
<th className="px-2 py-2">Last seen</th> >
<th className="px-2 py-2">Actions</th> Domain
<span
className={
domainSort.key === "domain"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("domain")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("status")}
className="inline-flex items-center gap-1"
>
Subscription Status
<span
className={
domainSort.key === "status"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("status")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("hosting")}
className="inline-flex items-center gap-1"
>
Hosting
<span
className={
domainSort.key === "hosting"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("hosting")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("created")}
className="inline-flex items-center gap-1"
>
Created
<span
className={
domainSort.key === "created"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("created")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("aliases")}
className="inline-flex items-center gap-1"
>
Aliases
<span
className={
domainSort.key === "aliases"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("aliases")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("last_seen")}
className="inline-flex items-center gap-1"
>
Last seen
<span
className={
domainSort.key === "last_seen"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("last_seen")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("actions")}
className="inline-flex items-center gap-1"
>
Actions
<span
className={
domainSort.key === "actions"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("actions")}
</span>
</button>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredDomains.length === 0 ? ( {sortedDomains.length === 0 ? (
<tr> <tr>
<td className="px-2 py-3 text-slate-500" colSpan={7}> <td className="px-2 py-3 text-slate-500" colSpan={7}>
No domains found. No domains found.
</td> </td>
</tr> </tr>
) : ( ) : (
filteredDomains.map((domain) => ( sortedDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100"> <tr key={domain.id} className="border-b border-slate-100">
<td className="px-2 py-2 font-medium text-slate-900"> <td className="px-2 py-2 font-medium text-slate-900">
<a <a

View File

@@ -1,21 +0,0 @@
services:
web:
container_name: plesk-agency-portal
image: ${APP_IMAGE:-plesk-agency-portal:latest}
restart: unless-stopped
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

View File

@@ -1,157 +0,0 @@
# Production Deployment Runtime Foundation
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
## Dockerfile purpose
The `Dockerfile` uses a multi-stage build on Node 20:
- installs dependencies
- runs `next build`
- prepares a lean runtime image with production dependencies
- runs the app with `next start` on port `3000`
This keeps the runtime image small and focused on serving the built Next.js app.
## docker-compose.prod.yml purpose
`docker-compose.prod.yml` defines one service:
- service name: `web`
- container name: `plesk-agency-portal`
- restart policy: `unless-stopped`
- environment from `.env`
- port mapping `3000:3000`
- health check against `/api/health`
## External Supabase requirement
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
## Health endpoint usage
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
Example:
```bash
curl -s http://localhost:3000/api/health
```
Expected response shape:
- `ok: true`
- `service: "plesk-agency-portal"`
- `timestamp`
- `environment`
## Production environment expectations
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
- app URL and `NODE_ENV`
- Supabase public and server credentials
- Plesk credential encryption key
- Stripe secrets
- cron/job shared secrets
Keep secrets out of source control.
## Reverse proxy assumptions (Nginx Proxy Manager)
This app is expected to run behind a reverse proxy in production.
- TLS is terminated at the proxy.
- Proxy forwards standard host/protocol headers (`Host`, `X-Forwarded-Proto`, `X-Forwarded-Host`).
- Public origin is configured explicitly with `NEXT_PUBLIC_APP_URL`.
To keep callback behavior stable behind proxy layers, auth redirect responses use the configured application origin from environment configuration rather than relying only on request host detection.
## Callback URL expectations
- `NEXT_PUBLIC_APP_URL` must be set to the public HTTPS URL used by users.
- Supabase auth redirect/callback configuration must allow:
- `<NEXT_PUBLIC_APP_URL>/auth/callback`
The callback route also validates the `next` parameter as an internal path to prevent unsafe external redirects.
## Cron protection expectations
Operational cron endpoints are protected with a shared secret.
- Required header: `x-cron-secret`
- Server secret source: `CRON_SHARED_SECRET`
- Backward compatibility fallback: `CRON_SECRET`
For new production setups, set `CRON_SHARED_SECRET` and use that value for all cron callers.
## Jenkins pipeline flow
The repository includes a declarative `Jenkinsfile` with the following stages:
1. **Checkout**
2. **Install dependencies** (`npm ci`)
3. **Lint** (runs only when `package.json` contains a `lint` script)
4. **Build application** (`npm run build`)
5. **Build Docker image** (tags `latest` and `build-${BUILD_NUMBER}`)
6. **Deploy** over SSH to the app host
7. **Smoke check** against `/api/health`
## Required Jenkins configuration
Configure these values in Jenkins job/folder/global environment or credentials-backed environment variables:
- `DEPLOY_SSH_HOST` - remote app host
- `DEPLOY_SSH_USER` - SSH user on remote host
- `DEPLOY_SSH_CREDENTIAL_ID` - Jenkins SSH Agent credential ID
- `DEPLOY_PATH` - remote deployment path (for example `/opt/plesk-agency-portal`)
- `APP_BASE_URL` - externally reachable app URL used by smoke check
The pipeline assumes Jenkins credentials are managed outside this repository.
Jenkins runtime requirements:
- The pipeline must run on a Jenkins agent with the `docker` label.
- That agent must have Docker CLI installed and permission to access Docker daemon/socket.
- `rsync` and `ssh` must also be available on the Jenkins agent.
If Docker is missing or inaccessible, the pipeline now fails early in a dedicated preflight stage with an explicit message.
## Deploy flow
Deployment is intentionally simple and bash-based:
1. Jenkins builds Docker image tags locally.
2. Jenkins syncs deployment files (`docker-compose.prod.yml`, deploy/smoke/rollback scripts) to the remote deploy path.
3. Jenkins streams Docker images to the remote host (`docker save | ssh docker load`).
4. Jenkins runs `scripts/deploy-prod.sh` remotely.
5. Script applies the selected image tag via:
```bash
APP_IMAGE=<tag> docker compose -f docker-compose.prod.yml up -d --no-build
```
6. Script prints `docker compose ps` status.
## Rollback flow
Rollback uses `scripts/rollback-prod.sh` with a previously built image tag:
```bash
DEPLOY_PATH=/opt/plesk-agency-portal \
APP_BASE_URL=https://your-app.example.com \
scripts/rollback-prod.sh plesk-agency-portal:build-123
```
Rollback redeploys that image with compose, then immediately reruns smoke checks.
## Smoke check behavior
`scripts/smoke-check.sh`:
- accepts a base URL argument
- requests `${BASE_URL}/api/health`
- fails on non-200 responses
- fails on invalid JSON or missing expected fields (`ok: true`, `service` string)
This gives a quick post-deploy validation gate for both deploy and rollback operations.

View File

@@ -3,18 +3,6 @@ const requiredEnvs = [
"NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
] as const; ] as const;
const configuredAppUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
function getAppOrigin() {
if (!configuredAppUrl) return "http://localhost:3000";
try {
return new URL(configuredAppUrl).origin;
} catch {
return "http://localhost:3000";
}
}
for (const key of requiredEnvs) { for (const key of requiredEnvs) {
if (!process.env[key]) { if (!process.env[key]) {
// Keep as runtime warning for smoother local setup. // Keep as runtime warning for smoother local setup.
@@ -24,12 +12,9 @@ for (const key of requiredEnvs) {
} }
export const env = { export const env = {
appUrl: configuredAppUrl ?? "http://localhost:3000", appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
appOrigin: getAppOrigin(),
jobSecret: process.env.JOB_SECRET ?? "", jobSecret: process.env.JOB_SECRET ?? "",
cronSecret: process.env.CRON_SECRET ?? "", cronSecret: process.env.CRON_SECRET ?? "",
cronSharedSecret:
process.env.CRON_SHARED_SECRET ?? process.env.CRON_SECRET ?? "",
encryptionKey: process.env.ENCRYPTION_KEY ?? "", encryptionKey: process.env.ENCRYPTION_KEY ?? "",
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "", pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "", stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",

View File

@@ -11,7 +11,7 @@ export function getStripeClient() {
if (!stripeClient) { if (!stripeClient) {
stripeClient = new Stripe(env.stripeSecretKey, { stripeClient = new Stripe(env.stripeSecretKey, {
apiVersion: "2025-08-27.basil", apiVersion: "2025-02-24.acacia",
}); });
} }

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
mkdir -p "${DEPLOY_PATH}"
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PREVIOUS_IMAGE_TAG="${1:-}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_BASE_URL="${APP_BASE_URL:-}"
if [ -z "${PREVIOUS_IMAGE_TAG}" ]; then
echo "Usage: $0 <previous-image-tag>" >&2
exit 1
fi
if [ -z "${APP_BASE_URL}" ]; then
echo "APP_BASE_URL is required" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build
"${DEPLOY_PATH}/smoke-check.sh" "${APP_BASE_URL}"
docker compose -f docker-compose.prod.yml ps

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-}"
if [ -z "${BASE_URL}" ]; then
echo "Usage: $0 <base-url>" >&2
exit 1
fi
URL="${BASE_URL%/}/api/health"
TMP_FILE="$(mktemp)"
trap 'rm -f "${TMP_FILE}"' EXIT
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}")
if [ "${HTTP_CODE}" != "200" ]; then
echo "Smoke check failed: ${URL} returned ${HTTP_CODE}" >&2
cat "${TMP_FILE}" >&2 || true
exit 1
fi
node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(d?.ok!==true||typeof d?.service!=='string'){process.exit(1)}" "${TMP_FILE}"
echo "Smoke check passed: ${URL}"