Compare commits

..

9 Commits

7 changed files with 321 additions and 63 deletions

View File

@@ -0,0 +1,147 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import {
suspendPleskSubscription,
unsuspendPleskSubscription,
} from "@/lib/plesk/client";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
const actionSchema = z.object({
action: z.enum(["suspend", "unsuspend"]),
});
export async function POST(
req: Request,
{ params }: { params: { id: string } },
) {
const supabase = createSupabaseRouteClient() as any;
let context: Awaited<ReturnType<typeof getRouteAgencyContextOrThrow>> | null =
null;
let domain: { id: string; subscription_id: string | null } | null = null;
let subscription: { id: string; plesk_subscription_id: string } | null = null;
let requestedAction: "suspend" | "unsuspend" | null = null;
try {
assertSameOrigin(req);
context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-domain-action:${context.userId}`, 10, 60_000);
const { action } = actionSchema.parse(await req.json());
requestedAction = action;
const { data: domainRow, error: domainError } = await supabase
.from("plesk_domains")
.select("id, agency_id, plesk_instance_id, subscription_id")
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
if (domainError || !domainRow) {
throw new Error("Domain not found");
}
domain = {
id: String(domainRow.id),
subscription_id: domainRow.subscription_id
? String(domainRow.subscription_id)
: null,
};
if (!domain.subscription_id) {
throw new Error("Domain is not linked to a subscription");
}
const { data: subscriptionRow, error: subscriptionError } = await supabase
.from("plesk_subscriptions")
.select("id, plesk_subscription_id, plesk_instance_id")
.eq("id", domain.subscription_id)
.eq("agency_id", context.agencyId)
.single();
if (subscriptionError || !subscriptionRow) {
throw new Error("Subscription not found");
}
subscription = {
id: String(subscriptionRow.id),
plesk_subscription_id: String(subscriptionRow.plesk_subscription_id),
};
const { data: instance, error: instanceError } = await supabase
.from("plesk_instances")
.select("id, base_url, auth_type, encrypted_secret")
.eq("id", subscriptionRow.plesk_instance_id)
.eq("agency_id", context.agencyId)
.single();
if (instanceError || !instance) {
throw new Error("Plesk instance not found");
}
const connection = {
baseUrl: instance.base_url,
authType: instance.auth_type,
encryptedSecret: instance.encrypted_secret,
} as const;
if (action === "suspend") {
await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
} else {
await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
}
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: `domain_${action}`,
status: "success",
metadata: {
domain_id: domain.id,
subscription_id: subscription.id,
plesk_subscription_id: subscription.plesk_subscription_id,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
if (context && domain) {
const message =
error instanceof Error ? error.message : "Domain action failed";
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: requestedAction ? `domain_${requestedAction}` : "domain_action",
status: "failed",
error_message: message,
metadata: {
domain_id: domain.id,
subscription_id: subscription?.id ?? domain.subscription_id,
plesk_subscription_id: subscription?.plesk_subscription_id ?? null,
},
});
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Domain action failed",
},
{ status: 400 },
);
}
}

View File

@@ -3,7 +3,6 @@ import { DashboardControls } from "@/components/dashboard/dashboard-controls";
import { getServerAgencyContextOrRedirect } from "@/lib/agency"; import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import type { Database } from "@/lib/types"; import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServerClient } from "@/lib/supabase/server";
import type { PostgrestError } from "@supabase/supabase-js";
type AgencySummary = Pick< type AgencySummary = Pick<
Database["public"]["Tables"]["agencies"]["Row"], Database["public"]["Tables"]["agencies"]["Row"],
@@ -51,7 +50,7 @@ export default async function DashboardPage() {
supabase supabase
.from("plesk_domains") .from("plesk_domains")
.select( .select(
"id, plesk_instance_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at", "id, plesk_instance_id, subscription_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at",
) )
.eq("agency_id", context.agencyId) .eq("agency_id", context.agencyId)
.order("updated_at", { ascending: false }) .order("updated_at", { ascending: false })
@@ -101,6 +100,7 @@ export default async function DashboardPage() {
data: Array<{ data: Array<{
id: string; id: string;
plesk_instance_id: string; plesk_instance_id: string;
subscription_id: string | null;
domain_name: string; domain_name: string;
status: string | null; status: string | null;
hosting_type: string | null; hosting_type: string | null;

View File

@@ -31,6 +31,7 @@ type Subscription = {
type Domain = { type Domain = {
id: string; id: string;
plesk_instance_id: string; plesk_instance_id: string;
subscription_id: string | null;
domain_name: string; domain_name: string;
status: string | null; status: string | null;
hosting_type: string | null; hosting_type: string | null;
@@ -52,15 +53,35 @@ type Props = {
function getDomainStatusBadge(status: string | null) { function getDomainStatusBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase(); const normalized = (status ?? "unknown").toLowerCase();
if (normalized === "active") return "bg-emerald-100 text-emerald-700"; if (normalized.includes("suspend") || normalized.includes("disabled")) {
return "bg-rose-100 text-rose-700";
}
if (normalized === "suspended") return "bg-rose-100 text-rose-700"; if (normalized.includes("active") || normalized.includes("enabled")) {
return "bg-emerald-100 text-emerald-700";
}
if (normalized === "inactive") return "bg-amber-100 text-amber-700"; if (normalized.includes("ok")) {
return "bg-emerald-100 text-emerald-700";
}
return "bg-slate-100 text-slate-600"; return "bg-slate-100 text-slate-600";
} }
function isDomainSuspended(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
return normalized.includes("suspend") || normalized.includes("disabled");
}
function isDomainActive(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
return (
normalized.includes("active") ||
normalized.includes("ok") ||
normalized.includes("enabled")
);
}
export function DashboardControls({ export function DashboardControls({
instances, instances,
subscriptions, subscriptions,
@@ -207,6 +228,25 @@ export function DashboardControls({
} }
} }
async function onDomainAction(
domainId: string,
action: "suspend" | "unsuspend",
) {
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/domains/${domainId}/action`, { action });
setMessage("Domain action completed.");
window.location.reload();
} catch (error) {
setMessage(
error instanceof Error ? error.message : "Domain action failed",
);
} finally {
setLoading(false);
}
}
async function onCheckout() { async function onCheckout() {
setLoading(true); setLoading(true);
try { try {
@@ -493,7 +533,7 @@ export function DashboardControls({
{message ? <p className="text-sm text-slate-600">{message}</p> : null} {message ? <p className="text-sm text-slate-600">{message}</p> : null}
</div> </div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4"> <div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 lg:col-span-2">
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-[220px]"> <div className="flex-1 min-w-[220px]">
<label className="text-sm font-medium">Search domains</label> <label className="text-sm font-medium">Search domains</label>
@@ -535,20 +575,26 @@ export function DashboardControls({
<th className="px-2 py-2">Created</th> <th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th> <th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th> <th className="px-2 py-2">Last seen</th>
<th className="px-2 py-2">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredDomains.length === 0 ? ( {filteredDomains.length === 0 ? (
<tr> <tr>
<td className="px-2 py-3 text-slate-500" colSpan={6}> <td className="px-2 py-3 text-slate-500" colSpan={7}>
No domains found. No domains found.
</td> </td>
</tr> </tr>
) : ( ) : (
filteredDomains.map((domain) => ( filteredDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100"> <tr key={domain.id} className="border-b border-slate-100">
<td className="font-medium text-slate-900"> <td className="px-2 py-2 font-medium text-slate-900">
{domain.domain_name} <p>{domain.domain_name}</p>
{domain.subscription_id ? (
<p className="text-xs font-normal text-slate-500">
Subscription: {domain.subscription_id}
</p>
) : null}
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
<span <span
@@ -567,6 +613,28 @@ export function DashboardControls({
<td className="px-2 py-2"> <td className="px-2 py-2">
{formatDateTime(domain.updated_at)} {formatDateTime(domain.updated_at)}
</td> </td>
<td className="px-2 py-2">
<div className="flex gap-2">
<button
onClick={() => onDomainAction(domain.id, "suspend")}
disabled={
loading || isDomainSuspended(domain.status)
}
className="rounded border border-rose-300 px-2 py-1 text-xs text-rose-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Suspend
</button>
<button
onClick={() =>
onDomainAction(domain.id, "unsuspend")
}
disabled={loading || isDomainActive(domain.status)}
className="rounded border border-emerald-300 px-2 py-1 text-xs text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Unsuspend
</button>
</div>
</td>
</tr> </tr>
)) ))
)} )}

View File

@@ -9,6 +9,7 @@ export type PleskConnection = {
}; };
export type PleskSubscription = { export type PleskSubscription = {
plesk_subscription_id?: string;
id?: string | number; id?: string | number;
guid?: string; guid?: string;
subscription_id?: string | number; subscription_id?: string | number;
@@ -137,51 +138,50 @@ export async function testPleskConnection(connection: PleskConnection) {
} }
export async function listPleskSubscriptions(connection: PleskConnection) { export async function listPleskSubscriptions(connection: PleskConnection) {
try { const listOutput = await pleskCliCall(connection, "subscription", ["--list"]);
const all: PleskSubscription[] = [];
for (let page = 1; page <= 20; page += 1) { const subscriptionNames = listOutput
const payload = await pleskRequest<PleskSubscription[]>(
connection,
`/api/v2/subscriptions?page=${page}&per_page=100`,
);
const rows = Array.isArray(payload) ? payload : [];
all.push(...rows);
if (rows.length < 100) break;
}
return all;
} catch {
const cli = await pleskRequest<PleskCliResult>(
connection,
"/api/v2/cli/subscription/call",
{
method: "POST",
body: JSON.stringify({ params: ["--list"] }),
},
);
const stdout = cli?.stdout ?? "";
if (!stdout.trim()) {
return [];
}
return stdout
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean);
.map((line) => {
const columns = line.split(/\s+/); const subscriptions: PleskSubscription[] = [];
const externalId = columns[0] ?? line;
return { for (const subscriptionName of subscriptionNames) {
id: externalId, let status = "unknown";
subscription_id: externalId,
external_id: externalId, try {
name: columns.slice(1).join(" ") || externalId, const infoOutput = await pleskCliCall(connection, "subscription", [
status: null, "--info",
}; subscriptionName,
]);
const normalizedInfo = infoOutput.toLowerCase();
const hasSuspendedMarker =
normalizedInfo.includes("suspended") ||
normalizedInfo.includes("webspace-off") ||
normalizedInfo.includes("disabled");
const hasActiveMarker =
normalizedInfo.includes("active") ||
normalizedInfo.includes("enabled") ||
normalizedInfo.includes("ok");
if (hasSuspendedMarker) status = "suspended";
else if (hasActiveMarker || !hasSuspendedMarker) status = "active";
} catch {
status = "unknown";
}
subscriptions.push({
plesk_subscription_id: subscriptionName,
subscription_id: subscriptionName,
name: subscriptionName,
status,
}); });
} }
return subscriptions;
} }
export async function pleskCliCall( export async function pleskCliCall(
@@ -208,7 +208,7 @@ export async function pleskCliCall(
); );
} }
return { code, stdout, stderr }; return stdout;
} }
export async function listPleskDomains(connection: PleskConnection) { export async function listPleskDomains(connection: PleskConnection) {

View File

@@ -82,13 +82,18 @@ export async function syncPleskInstance(
const subscriptionsForUpsert = subscriptionsRaw const subscriptionsForUpsert = subscriptionsRaw
.map((item: any) => { .map((item: any) => {
const sourceId = item?.id ?? item?.guid ?? item?.subscription_id; const sourceId =
item?.plesk_subscription_id ??
item?.name ??
item?.id ??
item?.guid ??
item?.subscription_id;
if (!sourceId) return null; if (!sourceId) return null;
return { return {
agency_id: instance.agency_id, agency_id: instance.agency_id,
plesk_instance_id: instance.id, plesk_instance_id: instance.id,
plesk_subscription_id: String(sourceId), plesk_subscription_id: String(sourceId),
name: item?.name ? String(item.name) : null, name: item?.name ? String(item.name) : String(sourceId),
status: item?.status ? String(item.status) : null, status: item?.status ? String(item.status) : null,
owner_login: item?.owner_login owner_login: item?.owner_login
? String(item.owner_login) ? String(item.owner_login)
@@ -113,15 +118,44 @@ export async function syncPleskInstance(
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
} }
const { data: localSubscriptions, error: localSubscriptionsError } =
await supabase
.from("plesk_subscriptions")
.select("id, plesk_subscription_id, status")
.eq("agency_id", instance.agency_id)
.eq("plesk_instance_id", instance.id);
if (localSubscriptionsError) {
throw new Error(localSubscriptionsError.message);
}
const subscriptionByExternalId = new Map<
string,
{ id: string; status: string | null }
>(
(localSubscriptions ?? [])
.filter((row: any) => row?.plesk_subscription_id && row?.id)
.map((row: any) => [
String(row.plesk_subscription_id),
{
id: String(row.id),
status: row?.status ? String(row.status) : null,
},
]),
);
const domainsForUpsert = domainsRaw const domainsForUpsert = domainsRaw
.map((item: any) => { .map((item: any) => {
const domainId = item?.id ?? item?.guid ?? item?.name; const domainId = item?.id ?? item?.guid ?? item?.name;
const domainName = item?.name ?? item?.domain_name ?? item?.domain; const domainName = item?.name ?? item?.domain_name ?? item?.domain;
const status = const subExternalId =
item?.status ??
(item?.hosting_type === "virtual" ? "active" : "inactive");
const externalSubscriptionId =
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id; item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
const externalSubscriptionId = subExternalId
? String(subExternalId)
: null;
const localSubscription = externalSubscriptionId
? subscriptionByExternalId.get(externalSubscriptionId)
: undefined;
const rawCreated = item?.created_at ?? item?.created; const rawCreated = item?.created_at ?? item?.created;
const sourceCreatedAt = rawCreated const sourceCreatedAt = rawCreated
? new Date(String(rawCreated)).toISOString() ? new Date(String(rawCreated)).toISOString()
@@ -138,13 +172,11 @@ export async function syncPleskInstance(
return { return {
agency_id: instance.agency_id, agency_id: instance.agency_id,
plesk_instance_id: instance.id, plesk_instance_id: instance.id,
subscription_id: null, subscription_id: localSubscription?.id ?? null,
external_subscription_id: externalSubscriptionId external_subscription_id: externalSubscriptionId,
? String(externalSubscriptionId)
: null,
plesk_domain_id: String(domainId), plesk_domain_id: String(domainId),
domain_name: String(domainName), domain_name: String(domainName),
status: status ? String(status) : "unknown", status: localSubscription?.status ?? "unknown",
hosting_type: item?.hosting_type ? String(item.hosting_type) : null, hosting_type: item?.hosting_type ? String(item.hosting_type) : null,
source_created_at: sourceCreatedAt, source_created_at: sourceCreatedAt,
aliases_count: aliasesCount, aliases_count: aliasesCount,

View File

@@ -0,0 +1,11 @@
-- One-time backfill: align existing domain status with linked subscription status
update public.plesk_domains d
set status = s.status
from public.plesk_subscriptions s
where d.subscription_id = s.id
and d.agency_id = s.agency_id
and (d.status is null or d.status = 'unknown');
update public.plesk_subscriptions
set status = 'unknown'
where status is null;

File diff suppressed because one or more lines are too long