Initial working state: CL3.5 complete (Plesk sync + suspend/unsuspend)

This commit is contained in:
2026-03-05 06:47:43 +00:00
commit a36d55eae5
53 changed files with 9925 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
ENCRYPTION_KEY=
PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID=

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.next
node_modules
.env.local
.env.*.local
dist
coverage
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store

36
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: dev server",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug server-side",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug client-side (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
],
"compounds": [
{
"name": "Next.js: full-stack debug",
"configurations": [
"Next.js: debug server-side",
"Next.js: debug client-side (Chrome)"
]
}
]
}

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# Plesk Agency Management Portal (MVP)
Next.js + Tailwind + Supabase + Stripe multi-tenant SaaS starter for agencies managing one or more Plesk instances.
## Included MVP Features
- Supabase Postgres schema + RLS (`supabase/schema.sql`)
- Supabase Auth (magic-link login)
- Agency tenancy context (`agency_members` based)
- Dashboard with:
- Plesk instance connect + validation
- Plesk sync (subscriptions + domains)
- Subscription suspend / unsuspend actions
- Stripe checkout + billing portal launch
- Stripe webhook endpoint to sync billing state
## 1) Setup
1. Install dependencies:
```bash
npm install
```
2. Create local env:
```bash
cp .env.example .env.local
```
3. Fill `.env.local` with your Supabase + Stripe values.
4. In Supabase SQL Editor, run:
```sql
-- contents of supabase/schema.sql
```
5. In Supabase Auth settings, set site URL and redirect URL(s), e.g.:
- `http://localhost:3000`
- `http://localhost:3000/dashboard`
6. Run dev server:
```bash
npm run dev
```
## 2) Seed Minimum Data
After first login user is created in `auth.users`, then add membership rows (via SQL) so tenant context resolves:
```sql
insert into agencies (name) values ('My Agency') returning id;
-- replace values
insert into agency_members (agency_id, user_id, role)
values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
```
## 3) API Endpoints
- `POST /api/plesk/instances` — create + validate Plesk connection
- `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance
- `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`)
- `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend
- `POST /api/stripe/checkout` — create Stripe Checkout session
- `POST /api/stripe/portal` — create Stripe Customer Portal session
- `POST /api/stripe/webhook` — Stripe webhook receiver
## 4) Important Notes
- Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets.
- Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption.
- Set `JOB_SECRET` for internal cron-triggered job auth.
- `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification.
- Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production.
## 5) Automatic Plesk Auto-Sync (CL3.5)
Background auto-sync endpoint:
```bash
curl -sS -X POST http://localhost:3000/api/jobs/plesk-sync-all \
-H "X-JOB-SECRET: <JOB_SECRET>"
```
Recommended cron (every 5 minutes):
```cron
*/5 * * * * curl -sS -X POST http://localhost:3000/api/jobs/plesk-sync-all -H "X-JOB-SECRET: ${JOB_SECRET}"
```
Notes:
- Job uses DB lock (`job_locks`) to avoid overlapping runs.
- Job processes at most 10 connected instances per run.
- Instances synced in the last 3 minutes are skipped.

49
SECURITY.md Normal file
View File

@@ -0,0 +1,49 @@
# Security Model (CL2)
## Tenancy model
- Tenant boundary is `agency_id`.
- Every business table row is scoped to an agency.
- RLS helper functions enforce scope using `auth.uid()` against `agency_members`:
- `public.is_agency_member(agency_id)`
- `public.is_agency_admin(agency_id)` (`owner` or `admin`)
## Role model
- `owner`: full admin permissions in agency
- `admin`: same operational permissions as owner for managed resources
- `member`: read-mostly on sensitive resources, no admin writes
## Write restrictions by table
- `agencies`
- `INSERT`: any authenticated user (supports first-time onboarding)
- `UPDATE/DELETE`: admin only
- `agency_members`
- `SELECT`: own membership rows
- `INSERT`: onboarding self-owner membership, or admin adding members
- `UPDATE/DELETE`: admin (delete also allows self-leave)
- `clients`
- member-readable and member-writable (chosen for MVP collaboration speed)
- `plesk_instances`
- member-readable, admin-writable
- `plesk_subscriptions`
- member-readable, admin-writable
- `plesk_domains`
- member-readable, admin-writable
- `actions_log`
- member-readable, admin insert/update
- `billing_accounts`
- member-readable, admin-writable
- `entitlements`
- member-readable, admin-writable
## Onboarding compatibility
Policies are designed so a newly authenticated user can:
1. insert first `agencies` row,
2. insert first `agency_members` row as `owner` for `auth.uid()`,
3. then operate normally under tenant-scoped access.
This supports the server-side auto-bootstrap flow used by `getServerAgencyContextOrRedirect()`.

View File

@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { syncPleskInstance } from "@/lib/plesk/sync";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
const JOB_NAME = "plesk_sync_all";
const MAX_INSTANCES_PER_RUN = 10;
export async function POST(req: Request) {
try {
const secret = req.headers.get("x-job-secret");
if (!env.jobSecret || !secret || secret !== env.jobSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const supabase = createSupabaseAdminClient() as any;
const lockOwner = `nextjs:${process.pid}:${Date.now()}`;
const { data: acquired, error: lockError } = await supabase.rpc(
"acquire_job_lock",
{
p_job_name: JOB_NAME,
p_locked_by: lockOwner,
p_ttl_seconds: 240,
},
);
if (lockError) {
throw new Error(`Unable to acquire job lock: ${lockError.message}`);
}
if (!acquired) {
return NextResponse.json({ skipped: true, reason: "already_running" });
}
try {
const { data: instances, error: instancesError } = await supabase
.from("plesk_instances")
.select(
"id, agency_id, base_url, auth_type, encrypted_secret, last_connected_at",
)
.eq("status", "connected")
.order("last_connected_at", { ascending: true, nullsFirst: true })
.limit(MAX_INSTANCES_PER_RUN);
if (instancesError) throw instancesError;
const results: Array<Record<string, unknown>> = [];
for (const instance of instances ?? []) {
try {
const result = await syncPleskInstance(
supabase,
instance,
null,
"plesk_sync_auto",
);
results.push(result);
} catch (error) {
const message =
error instanceof Error
? error.message.slice(0, 1000)
: "Auto sync failed";
await supabase
.from("plesk_instances")
.update({ status: "error", error_message: message })
.eq("id", instance.id)
.eq("agency_id", instance.agency_id);
await supabase.from("actions_log").insert({
agency_id: instance.agency_id,
actor_user_id: null,
target_type: "plesk_instance",
target_id: instance.id,
action: "plesk_sync_auto",
status: "failed",
error_message: message,
metadata: { auto: true },
});
results.push({
instanceId: instance.id,
agencyId: instance.agency_id,
skipped: false,
failed: true,
error: message,
});
}
}
return NextResponse.json({
skipped: false,
processed: results.length,
maxPerRun: MAX_INSTANCES_PER_RUN,
results,
});
} finally {
await supabase.rpc("release_job_lock", { p_job_name: JOB_NAME });
}
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Job failed" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import { syncPleskInstance } from "@/lib/plesk/sync";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
export async function POST(
req: Request,
{ params }: { params: { id: string } },
) {
try {
assertSameOrigin(req);
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-sync:${context.userId}`, 8, 60_000);
const supabase = createSupabaseRouteClient() as any;
const { data: instance, error: instanceError } = await supabase
.from("plesk_instances")
.select(
"id, agency_id, base_url, auth_type, encrypted_secret, last_connected_at",
)
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
if (instanceError || !instance) throw new Error("Plesk instance not found");
const result = await syncPleskInstance(
supabase,
instance,
context.userId,
"sync",
);
return NextResponse.json({
subscriptionsUpserted: result.subscriptionsUpserted,
domainsUpserted: result.domainsUpserted,
skipped: result.skipped,
reason: result.reason,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Sync failed" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import { testPleskConnection } from "@/lib/plesk/client";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
export async function POST(
req: Request,
{ params }: { params: { id: string } },
) {
try {
assertSameOrigin(req);
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-test:${context.userId}`, 20, 60_000);
const supabase = createSupabaseRouteClient() as any;
const { data: instance, error: loadError } = await supabase
.from("plesk_instances")
.select("id, agency_id, base_url, auth_type, encrypted_secret")
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
if (loadError || !instance) {
throw new Error("Plesk instance not found");
}
let status: "connected" | "error" = "connected";
let errorMessage: string | null = null;
try {
await testPleskConnection({
baseUrl: instance.base_url,
authType: instance.auth_type,
encryptedSecret: instance.encrypted_secret,
});
} catch (error) {
status = "error";
errorMessage =
error instanceof Error
? error.message.slice(0, 1000)
: "Connection test failed";
}
const { data: updated } = await supabase
.from("plesk_instances")
.update({
status,
error_message: errorMessage,
last_connected_at:
status === "connected" ? new Date().toISOString() : null,
})
.eq("id", instance.id)
.eq("agency_id", context.agencyId)
.select(
"id, name, base_url, auth_type, status, error_message, last_connected_at",
)
.single();
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_instance",
target_id: instance.id,
action: "test_connection",
status: status === "connected" ? "success" : "failed",
error_message: errorMessage,
metadata: { instance_id: instance.id },
});
return NextResponse.json({ ok: status === "connected", instance: updated });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Connection test failed",
},
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,122 @@
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 { encryptPleskSecret } from "@/lib/crypto";
import { testPleskConnection } from "@/lib/plesk/client";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
const createSchema = z.object({
name: z.string().min(2),
base_url: z.string().url(),
auth_type: z.enum(["api_key", "basic"]),
api_key: z.string().min(8).optional(),
username: z.string().min(1).optional(),
password: z.string().min(1).optional(),
});
export async function POST(req: Request) {
try {
assertSameOrigin(req);
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-connect:${context.userId}`, 10, 60_000);
const input = createSchema.parse(await req.json());
const payload =
input.auth_type === "api_key"
? {
...input,
secret: input.api_key,
}
: {
...input,
secret:
input.username && input.password
? `${input.username}:${input.password}`
: undefined,
};
if (!payload.secret) {
throw new Error("Missing credentials for selected auth type");
}
const supabase = createSupabaseRouteClient() as any;
const { encryptedSecret, secretKid } = encryptPleskSecret(payload.secret);
try {
await testPleskConnection({
baseUrl: payload.base_url,
authType: payload.auth_type,
encryptedSecret: encryptedSecret,
});
} catch (connectError) {
const errorMessage =
connectError instanceof Error
? connectError.message.slice(0, 1000)
: "Connection test failed";
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_instance",
target_id: null,
action: "connect",
status: "failed",
error_message: errorMessage,
metadata: { base_url: payload.base_url, auth_type: payload.auth_type },
});
throw new Error(errorMessage);
}
const { data: instance, error } = await supabase
.from("plesk_instances")
.insert({
agency_id: context.agencyId,
name: payload.name,
base_url: payload.base_url,
auth_type: payload.auth_type,
encrypted_secret: encryptedSecret,
secret_kid: secretKid,
status: "connected",
error_message: null,
last_connected_at: new Date().toISOString(),
})
.select(
"id, name, base_url, auth_type, status, error_message, last_connected_at",
)
.single();
if (error || !instance) {
throw new Error(error?.message ?? "Failed to create Plesk instance");
}
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_instance",
target_id: instance.id,
action: "connect",
status: "success",
error_message: null,
metadata: { base_url: payload.base_url, auth_type: payload.auth_type },
});
return NextResponse.json({ instance });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to create Plesk instance",
},
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,133 @@
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 } },
) {
let requestedAction: "suspend" | "unsuspend" | null = null;
const supabase = createSupabaseRouteClient() as any;
let context: Awaited<ReturnType<typeof getRouteAgencyContextOrThrow>> | null =
null;
let subscription: {
id: string;
plesk_subscription_id: string;
plesk_instance_id: string;
} | null = null;
try {
assertSameOrigin(req);
context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-subscription-action:${context.userId}`, 20, 60_000);
const payload = actionSchema.parse(await req.json());
requestedAction = payload.action;
const { data, error } = await supabase
.from("plesk_subscriptions")
.select("id, plesk_subscription_id, plesk_instance_id")
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
subscription = data;
if (error || !subscription) {
throw new Error("Subscription not found");
}
const { data: instance, error: instanceError } = await supabase
.from("plesk_instances")
.select("id, base_url, auth_type, encrypted_secret")
.eq("id", subscription.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 (payload.action === "suspend") {
await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
} else {
await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
}
const newStatus = payload.action === "suspend" ? "suspended" : "active";
const { error: updateError } = await supabase
.from("plesk_subscriptions")
.update({ status: newStatus })
.eq("id", subscription.id);
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_subscription",
target_id: subscription.id,
action: payload.action,
status: "success",
metadata: {
plesk_subscription_id: subscription.plesk_subscription_id,
db_status_update_error: updateError?.message ?? null,
},
});
return NextResponse.json({
ok: true,
status: newStatus,
dbStatusUpdated: !updateError,
});
} catch (error) {
if (supabase && context && subscription) {
const message =
error instanceof Error ? error.message : "Subscription action failed";
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_subscription",
target_id: subscription.id,
action: requestedAction ?? "subscription_action",
status: "failed",
error_message: message,
metadata: {
plesk_subscription_id: subscription.plesk_subscription_id,
},
});
}
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Subscription action failed",
},
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,60 @@
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 { syncPleskInstance } from "@/lib/plesk/sync";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
const syncSchema = z.object({
instanceId: z.string().uuid(),
});
export async function POST(req: Request) {
try {
assertSameOrigin(req);
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-sync:${context.userId}`, 8, 60_000);
const payload = syncSchema.parse(await req.json());
const supabase = createSupabaseRouteClient() as any;
const {
data: { user },
} = await supabase.auth.getUser();
console.log("SYNC USER:", user?.id, user?.email);
const { data: instance, error: instanceError } = await supabase
.from("plesk_instances")
.select(
"id, agency_id, base_url, auth_type, encrypted_secret, last_connected_at",
)
.eq("id", payload.instanceId)
.eq("agency_id", context.agencyId)
.single();
if (instanceError || !instance) throw new Error("Plesk instance not found");
const result = await syncPleskInstance(
supabase,
instance,
context.userId,
"sync",
);
return NextResponse.json({
subscriptionsUpserted: result.subscriptionsUpserted,
domainsUpserted: result.domainsUpserted,
skipped: result.skipped,
reason: result.reason,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Sync failed" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { env } from "@/lib/env";
import { getStripeClient } from "@/lib/stripe";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
export async function POST() {
try {
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
if (!env.stripePriceId) {
throw new Error("STRIPE_PRICE_ID is not configured");
}
const stripe = getStripeClient();
const supabase = createSupabaseRouteClient();
const { data: billing, error: billingError } = await supabase
.from("billing_accounts")
.select("id, stripe_customer_id")
.eq("agency_id", context.agencyId)
.maybeSingle();
if (billingError) throw billingError;
let customerId = billing?.stripe_customer_id ?? null;
if (!customerId) {
const customer = await stripe.customers.create({
metadata: {
agency_id: context.agencyId,
},
});
customerId = customer.id;
const upsertPayload = {
agency_id: context.agencyId,
stripe_customer_id: customerId,
};
if (billing?.id) {
await supabase.from("billing_accounts").update(upsertPayload).eq("id", billing.id);
} else {
await supabase.from("billing_accounts").insert(upsertPayload);
}
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: env.stripePriceId, quantity: 1 }],
success_url: `${env.appUrl}/dashboard?checkout=success`,
cancel_url: `${env.appUrl}/dashboard?checkout=cancelled`,
metadata: {
agency_id: context.agencyId,
},
});
return NextResponse.json({ url: session.url });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create checkout session" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { env } from "@/lib/env";
import { getStripeClient } from "@/lib/stripe";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
export async function POST() {
try {
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
const supabase = createSupabaseRouteClient();
const stripe = getStripeClient();
const { data: billing, error } = await supabase
.from("billing_accounts")
.select("stripe_customer_id")
.eq("agency_id", context.agencyId)
.maybeSingle();
if (error) throw error;
if (!billing?.stripe_customer_id) {
throw new Error("No Stripe customer is linked to this agency");
}
const session = await stripe.billingPortal.sessions.create({
customer: billing.stripe_customer_id,
return_url: `${env.appUrl}/dashboard`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to open billing portal" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { env } from "@/lib/env";
import { getStripeClient } from "@/lib/stripe";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
function toIsoOrNull(unixSeconds?: number | null) {
if (!unixSeconds) return null;
return new Date(unixSeconds * 1000).toISOString();
}
async function upsertBillingByCustomer(
customerId: string,
updates: {
stripe_subscription_id?: string | null;
plan_id?: string | null;
subscription_status?: string | null;
current_period_end?: string | null;
},
) {
const supabase = createSupabaseAdminClient();
const { data: existing } = await supabase
.from("billing_accounts")
.select("id")
.eq("stripe_customer_id", customerId)
.maybeSingle();
if (existing?.id) {
await supabase.from("billing_accounts").update(updates).eq("id", existing.id);
}
}
async function handleCheckoutSessionCompleted(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;
if (!agencyId || !customerId) return;
const supabase = createSupabaseAdminClient();
const payload = {
agency_id: agencyId,
stripe_customer_id: customerId,
stripe_subscription_id:
typeof session.subscription === "string" ? (session.subscription as string) : null,
subscription_status: "active",
};
const { data: existing } = await supabase
.from("billing_accounts")
.select("id")
.eq("agency_id", agencyId)
.maybeSingle();
if (existing?.id) {
await supabase.from("billing_accounts").update(payload).eq("id", existing.id);
} else {
await supabase.from("billing_accounts").insert(payload);
}
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
if (!customerId) return;
await upsertBillingByCustomer(customerId, {
stripe_subscription_id: subscription.id,
plan_id: subscription.items.data[0]?.price.id ?? null,
subscription_status: subscription.status,
current_period_end: toIsoOrNull(subscription.current_period_end),
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
if (!customerId) return;
await upsertBillingByCustomer(customerId, {
stripe_subscription_id: subscription.id,
subscription_status: "canceled",
current_period_end: toIsoOrNull(subscription.current_period_end),
});
}
export async function POST(req: Request) {
try {
if (!env.stripeWebhookSecret) {
throw new Error("STRIPE_WEBHOOK_SECRET is not configured");
}
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
}
const stripe = getStripeClient();
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, signature, env.stripeWebhookSecret);
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
default:
break;
}
return NextResponse.json({ received: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Webhook handling failed" },
{ status: 400 },
);
}
}

View File

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

163
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,163 @@
import { SignOutButton } from "@/components/auth/sign-out-button";
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server";
type AgencySummary = Pick<Database["public"]["Tables"]["agencies"]["Row"], "id" | "name">;
type BillingSummary = Pick<
Database["public"]["Tables"]["billing_accounts"]["Row"],
"subscription_status" | "plan_id" | "current_period_end"
>;
type ActionLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
"target_id" | "status" | "action" | "created_at" | "error_message"
>;
export default async function DashboardPage() {
const context = await getServerAgencyContextOrRedirect();
const supabase = createSupabaseServerClient() as any;
const [
{ data: agency },
{ data: instances },
{ data: subscriptions },
{ data: domains },
{ data: billing },
{ data: autoSyncLogs },
] =
(await Promise.all([
supabase.from("agencies").select("id, name").eq("id", context.agencyId).maybeSingle(),
supabase
.from("plesk_instances")
.select(
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains",
)
.eq("agency_id", context.agencyId)
.order("created_at", { ascending: false }),
supabase
.from("plesk_subscriptions")
.select("id, name, status, plesk_subscription_id, plesk_instance_id")
.eq("agency_id", context.agencyId)
.order("updated_at", { ascending: false })
.limit(10),
supabase
.from("plesk_domains")
.select(
"id, plesk_instance_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at",
)
.eq("agency_id", context.agencyId)
.order("updated_at", { ascending: false })
.limit(500),
supabase
.from("billing_accounts")
.select("subscription_status, plan_id, current_period_end")
.eq("agency_id", context.agencyId)
.maybeSingle(),
supabase
.from("actions_log")
.select("target_id, status, action, created_at, error_message")
.eq("agency_id", context.agencyId)
.eq("target_type", "plesk_instance")
.eq("action", "plesk_sync_auto")
.order("created_at", { ascending: false })
.limit(200),
])) as [
{ data: AgencySummary | null },
{
data:
| Array<{
id: string;
name: string;
base_url: string;
auth_type: "api_key" | "basic";
status: string;
error_message: string | null;
last_connected_at: string | null;
last_sync_at: string | null;
last_sync_status: string | null;
last_sync_error: string | null;
last_sync_subscriptions: number;
last_sync_domains: number;
}>
| null;
},
{
data:
| Array<{
id: string;
name: string | null;
status: string | null;
plesk_subscription_id: string;
plesk_instance_id: string;
}>
| null;
},
{
data:
| Array<{
id: string;
plesk_instance_id: string;
domain_name: string;
status: string | null;
hosting_type: string | null;
source_created_at: string | null;
aliases_count: number | null;
updated_at: string;
}>
| null;
},
{ data: BillingSummary | null },
{ data: ActionLogSummary[] | null },
];
const autoSyncByInstance = new Map<
string,
{ status: string; created_at: string; error_message: string | null }
>();
for (const log of autoSyncLogs ?? []) {
if (!log.target_id || autoSyncByInstance.has(log.target_id)) continue;
autoSyncByInstance.set(log.target_id, {
status: log.status,
created_at: log.created_at,
error_message: log.error_message,
});
}
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">{agency?.name ?? "Agency"} Dashboard</h1>
<p className="text-sm text-slate-600">Role: {context.role}</p>
</div>
<SignOutButton />
</header>
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Plesk Instances</p>
<p className="text-2xl font-semibold text-slate-900">{instances?.length ?? 0}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Tracked Subscriptions</p>
<p className="text-2xl font-semibold text-slate-900">{subscriptions?.length ?? 0}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Billing Status</p>
<p className="text-2xl font-semibold text-slate-900">{billing?.subscription_status ?? "none"}</p>
</div>
</section>
<DashboardControls
instances={instances ?? []}
subscriptions={subscriptions ?? []}
domains={domains ?? []}
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
/>
</main>
);
}

12
app/globals.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
@apply bg-slate-50 text-slate-900;
}
a {
@apply text-brand-600 hover:text-brand-500;
}

18
app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "Plesk Agency Portal",
description: "Multi-tenant agency management portal for Plesk hosting",
};
export default function RootLayout({
children,
}: Readonly<{ children: ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

22
app/login/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Link from "next/link";
import { LoginForm } from "@/components/auth/login-form";
export default function LoginPage() {
return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center gap-6 px-6">
<div>
<h1 className="text-3xl font-bold text-slate-900">Sign in</h1>
<p className="mt-2 text-sm text-slate-600">
Use your agency email to receive a magic-link and access the portal.
</p>
</div>
<LoginForm />
<Link href="/" className="text-sm text-slate-500">
Back to home
</Link>
</main>
);
}

24
app/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-6 px-6">
<h1 className="text-4xl font-bold text-slate-900">Plesk Agency Portal</h1>
<p className="max-w-2xl text-slate-600">
Multi-tenant MVP for agencies to connect Plesk instances, manage subscriptions and
domains, and control billing with Stripe.
</p>
<div className="flex items-center gap-3">
<Link
href="/login"
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white"
>
Sign in
</Link>
<Link href="/dashboard" className="rounded-lg border border-slate-300 px-4 py-2 text-sm">
Dashboard
</Link>
</div>
</main>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import type { FormEvent } from "react";
import { createSupabaseBrowserClient } from "@/lib/supabase/browser";
export function LoginForm() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setMessage(null);
try {
const supabase = createSupabaseBrowserClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
setMessage(error.message);
} else {
setMessage("Check your email for the sign-in link.");
}
} finally {
setLoading(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-slate-700">
Work email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-brand-500"
placeholder="you@agency.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-70"
>
{loading ? "Sending..." : "Send magic link"}
</button>
{message ? <p className="text-sm text-slate-600">{message}</p> : null}
</form>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { createSupabaseBrowserClient } from "@/lib/supabase/browser";
export function SignOutButton() {
const router = useRouter();
async function onSignOut() {
const supabase = createSupabaseBrowserClient();
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
return (
<button
type="button"
onClick={onSignOut}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-700"
>
Sign out
</button>
);
}

View File

@@ -0,0 +1,472 @@
"use client";
import { useState } from "react";
import type { ChangeEvent } from "react";
import type { FormEvent } from "react";
type Instance = {
id: string;
name: string;
base_url: string;
auth_type: "api_key" | "basic";
status: string;
error_message: string | null;
last_connected_at: string | null;
last_sync_at: string | null;
last_sync_status: string | null;
last_sync_error: string | null;
last_sync_subscriptions: number;
last_sync_domains: number;
};
type Subscription = {
id: string;
name: string | null;
status: string | null;
plesk_subscription_id: string;
plesk_instance_id: string;
};
type Domain = {
id: string;
plesk_instance_id: string;
domain_name: string;
status: string | null;
hosting_type: string | null;
source_created_at: string | null;
aliases_count: number | null;
updated_at: string;
};
type Props = {
instances: Instance[];
subscriptions: Subscription[];
domains: Domain[];
autoSyncByInstance: Record<
string,
{ status: string; created_at: string; error_message: string | null }
>;
};
export function DashboardControls({
instances,
subscriptions,
domains,
autoSyncByInstance,
}: Props) {
const [instanceName, setInstanceName] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [authType, setAuthType] = useState<"api_key" | "basic">("api_key");
const [apiKey, setApiKey] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [actionSubId, setActionSubId] = useState(subscriptions[0]?.id ?? "");
const [actionType, setActionType] = useState<"suspend" | "unsuspend">("suspend");
const [domainSearch, setDomainSearch] = useState("");
const [domainStatus, setDomainStatus] = useState("all");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const healthCounts = instances.reduce(
(acc, instance) => {
if (instance.status === "connected") acc.connected += 1;
else if (instance.status === "syncing") acc.syncing += 1;
else acc.error += 1;
return acc;
},
{ connected: 0, syncing: 0, error: 0 },
);
const domainStatusOptions = Array.from(
new Set(domains.map((domain) => (domain.status ?? "unknown").toLowerCase())),
).sort();
const filteredDomains = domains.filter((domain) => {
const normalizedStatus = (domain.status ?? "unknown").toLowerCase();
const statusMatches = domainStatus === "all" || normalizedStatus === domainStatus;
const term = domainSearch.trim().toLowerCase();
const searchMatches =
!term ||
domain.domain_name.toLowerCase().includes(term) ||
(domain.hosting_type ?? "").toLowerCase().includes(term);
return statusMatches && searchMatches;
});
function formatDate(value: string | null) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
}
async function runRequest(url: string, body?: unknown) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error ?? "Request failed");
}
return payload;
}
async function onCreateInstance(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setMessage(null);
try {
await runRequest("/api/plesk/instances", {
name: instanceName,
base_url: baseUrl,
auth_type: authType,
api_key: authType === "api_key" ? apiKey : undefined,
username: authType === "basic" ? username : undefined,
password: authType === "basic" ? password : undefined,
});
setMessage("Plesk instance connected.");
window.location.reload();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Could not connect instance");
} finally {
setLoading(false);
}
}
async function onSync(instanceId: string) {
setLoading(true);
setMessage(null);
try {
const payload = await runRequest(`/api/plesk/instances/${instanceId}/sync`);
setMessage("Plesk sync completed.");
if (payload?.skipped) {
setMessage(`Sync skipped (${payload.reason ?? "no reason"}).`);
} else if (payload?.domainsUpserted != null || payload?.subscriptionsUpserted != null) {
setMessage(
`Plesk sync completed. Subscriptions: ${payload.subscriptionsUpserted ?? 0}, Domains: ${payload.domainsUpserted ?? 0}`,
);
}
window.location.reload();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Sync failed");
} finally {
setLoading(false);
}
}
async function onTestConnection(instanceId: string) {
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/instances/${instanceId}/test`);
setMessage("Connection test completed.");
window.location.reload();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Connection test failed");
} finally {
setLoading(false);
}
}
async function onSubscriptionAction() {
if (!actionSubId) return;
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/subscriptions/${actionSubId}/action`, { action: actionType });
setMessage(`Subscription ${actionType} completed.`);
window.location.reload();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Action failed");
} finally {
setLoading(false);
}
}
async function onCheckout() {
setLoading(true);
try {
const payload = await runRequest("/api/stripe/checkout");
if (payload.url) window.location.href = payload.url;
} finally {
setLoading(false);
}
}
async function onPortal() {
setLoading(true);
try {
const payload = await runRequest("/api/stripe/portal");
if (payload.url) window.location.href = payload.url;
} finally {
setLoading(false);
}
}
return (
<section className="grid gap-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<p className="text-sm text-emerald-700">Connected</p>
<p className="text-2xl font-semibold text-emerald-900">{healthCounts.connected}</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-700">Syncing</p>
<p className="text-2xl font-semibold text-amber-900">{healthCounts.syncing}</p>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4">
<p className="text-sm text-rose-700">Error/Other</p>
<p className="text-2xl font-semibold text-rose-900">{healthCounts.error}</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<form onSubmit={onCreateInstance} className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h2 className="text-lg font-semibold">Connect Plesk Instance</h2>
<input
required
value={instanceName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setInstanceName(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
placeholder="Instance name"
/>
<input
required
value={baseUrl}
onChange={(e: ChangeEvent<HTMLInputElement>) => setBaseUrl(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
placeholder="https://plesk.example.com"
/>
<select
value={authType}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
setAuthType(e.target.value as "api_key" | "basic")
}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
>
<option value="api_key">API Key</option>
<option value="basic">Basic</option>
</select>
{authType === "api_key" ? (
<input
required
value={apiKey}
onChange={(e: ChangeEvent<HTMLInputElement>) => setApiKey(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
placeholder="Plesk API key"
/>
) : (
<div className="grid gap-2 sm:grid-cols-2">
<input
required
value={username}
onChange={(e: ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
placeholder="Plesk username"
/>
<input
required
type="password"
value={password}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
placeholder="Plesk password"
/>
</div>
)}
<button disabled={loading} className="rounded bg-brand-600 px-3 py-2 text-sm text-white">
Save & Validate
</button>
</form>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h2 className="text-lg font-semibold">Plesk Instances</h2>
<div className="space-y-2">
{instances.length === 0 ? (
<p className="text-sm text-slate-500">No Plesk instances connected yet.</p>
) : (
instances.map((instance) => (
<div key={instance.id} className="rounded border border-slate-200 p-3">
<div className="flex items-start justify-between gap-2">
<div>
{autoSyncByInstance[instance.id] ? (
<span
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
autoSyncByInstance[instance.id].status === "success"
? "bg-emerald-100 text-emerald-700"
: "bg-rose-100 text-rose-700"
}`}
>
Auto-sync {autoSyncByInstance[instance.id].status}
</span>
) : null}
<p className="text-sm font-semibold text-slate-900">{instance.name}</p>
<p className="text-xs text-slate-500">{instance.base_url}</p>
<p className="mt-1 text-xs text-slate-600">
Status: <span className="font-medium">{instance.status}</span>
</p>
<p className="text-xs text-slate-500">
Last sync: {formatDate(instance.last_sync_at)}
{instance.last_sync_status ? ` (${instance.last_sync_status})` : ""}
</p>
<p className="text-xs text-slate-500">
Last counts: {instance.last_sync_subscriptions ?? 0} subscriptions, {" "}
{instance.last_sync_domains ?? 0} domains
</p>
<p className="text-xs text-slate-500">
Last connected: {instance.last_connected_at ?? "never"}
</p>
{autoSyncByInstance[instance.id] ? (
<p className="text-xs text-slate-500">
Last auto-sync: {autoSyncByInstance[instance.id].created_at}
</p>
) : null}
{instance.error_message ? (
<p className="mt-1 text-xs text-rose-600">{instance.error_message}</p>
) : null}
{instance.last_sync_error ? (
<p className="mt-1 text-xs text-rose-600">
Last sync error: {instance.last_sync_error}
</p>
) : null}
{autoSyncByInstance[instance.id]?.error_message ? (
<p className="mt-1 text-xs text-rose-600">
Auto-sync error: {autoSyncByInstance[instance.id].error_message}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<button
onClick={() => onTestConnection(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Test Connection
</button>
<button
onClick={() => onSync(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Sync Now
</button>
</div>
</div>
</div>
))
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Subscription Action</label>
<select
value={actionSubId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setActionSubId(e.target.value)}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
>
{subscriptions.map((subscription) => (
<option key={subscription.id} value={subscription.id}>
{subscription.name ?? subscription.plesk_subscription_id}
</option>
))}
</select>
<select
value={actionType}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
setActionType(e.target.value as "suspend" | "unsuspend")
}
className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
>
<option value="suspend">Suspend</option>
<option value="unsuspend">Unsuspend</option>
</select>
<button
onClick={onSubscriptionAction}
disabled={loading || !actionSubId}
className="rounded border px-3 py-2 text-sm"
>
Execute Action
</button>
</div>
<div className="flex gap-2 pt-2">
<button onClick={onCheckout} disabled={loading} className="rounded bg-brand-600 px-3 py-2 text-sm text-white">
Start Checkout
</button>
<button onClick={onPortal} disabled={loading} className="rounded border px-3 py-2 text-sm">
Billing Portal
</button>
</div>
{message ? <p className="text-sm text-slate-600">{message}</p> : null}
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-[220px]">
<label className="text-sm font-medium">Search domains</label>
<input
value={domainSearch}
onChange={(e: ChangeEvent<HTMLInputElement>) => setDomainSearch(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-3 py-2 text-sm"
placeholder="Search by domain or hosting type"
/>
</div>
<div className="min-w-[180px]">
<label className="text-sm font-medium">Status filter</label>
<select
value={domainStatus}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setDomainStatus(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-3 py-2 text-sm"
>
<option value="all">All statuses</option>
{domainStatusOptions.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<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">Status</th>
<th className="px-2 py-2">Hosting</th>
<th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th>
</tr>
</thead>
<tbody>
{filteredDomains.length === 0 ? (
<tr>
<td className="px-2 py-3 text-slate-500" colSpan={6}>
No domains found.
</td>
</tr>
) : (
filteredDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100">
<td className="px-2 py-2 font-medium text-slate-900">{domain.domain_name}</td>
<td className="px-2 py-2">{domain.status ?? "unknown"}</td>
<td className="px-2 py-2">{domain.hosting_type ?? "unknown"}</td>
<td className="px-2 py-2">{formatDate(domain.source_created_at)}</td>
<td className="px-2 py-2">{domain.aliases_count ?? 0}</td>
<td className="px-2 py-2">{formatDate(domain.updated_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</section>
);
}

139
lib/agency.ts Normal file
View File

@@ -0,0 +1,139 @@
import { redirect } from "next/navigation";
import type { Database } from "@/lib/types";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
import { createSupabaseServerClient } from "@/lib/supabase/server";
export type AgencyContext = {
userId: string;
agencyId: string;
role: "owner" | "admin" | "member";
};
export function assertAgencyAdmin(context: AgencyContext) {
if (context.role !== "owner" && context.role !== "admin") {
throw new Error("Forbidden");
}
}
type AgencyMemberSummary = Pick<
Database["public"]["Tables"]["agency_members"]["Row"],
"agency_id" | "role"
>;
async function getOrCreateAgencyMembership(
userId: string,
email?: string | null,
) {
const supabase = createSupabaseServerClient() as any;
const admin = createSupabaseAdminClient() as any;
const { data: existingMembership } = (await supabase
.from("agency_members")
.select("agency_id, role")
.eq("user_id", userId)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle()) as { data: AgencyMemberSummary | null };
if (existingMembership) {
return existingMembership;
}
const defaultAgencyName = email
? `${email.split("@")[0] ?? "New"} Agency`
: "New Agency";
const { data: createdAgency, error: agencyError } = (await admin
.from("agencies")
.insert({ name: defaultAgencyName })
.select("id")
.single()) as {
data: Pick<Database["public"]["Tables"]["agencies"]["Row"], "id"> | null;
error: { message: string } | null;
};
if (agencyError || !createdAgency) {
throw new Error(
`Unable to create agency: ${agencyError?.message ?? "no row returned"}`,
);
}
const { error: membershipError } = await admin.from("agency_members").insert({
agency_id: createdAgency.id,
user_id: userId,
role: "owner",
});
if (!membershipError) {
return {
agency_id: createdAgency.id,
role: "owner" as const,
};
}
// If another request created membership first, fall back to a fresh read.
const { data: retriedMembership } = (await supabase
.from("agency_members")
.select("agency_id, role")
.eq("user_id", userId)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle()) as { data: AgencyMemberSummary | null };
if (retriedMembership) {
return retriedMembership;
}
throw new Error("Unable to create agency membership");
}
export async function getServerAgencyContextOrRedirect(): Promise<AgencyContext> {
const supabase = createSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const membership = await getOrCreateAgencyMembership(user.id, user.email);
return {
userId: user.id,
agencyId: membership.agency_id,
role: membership.role,
};
}
export async function getRouteAgencyContextOrThrow(): Promise<AgencyContext> {
const supabase = createSupabaseRouteClient();
const db = supabase as any;
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const { data: membership } = (await db
.from("agency_members")
.select("agency_id, role")
.eq("user_id", session.user.id)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle()) as { data: AgencyMemberSummary | null };
if (!membership) {
throw new Error("No agency membership");
}
return {
userId: session.user.id,
agencyId: membership.agency_id,
role: membership.role,
};
}

18
lib/api/rate-limit.ts Normal file
View File

@@ -0,0 +1,18 @@
const buckets = new Map<string, number[]>();
export function assertRateLimit(
key: string,
maxRequests: number,
windowMs: number,
) {
const now = Date.now();
const start = now - windowMs;
const hits = (buckets.get(key) ?? []).filter((ts) => ts >= start);
if (hits.length >= maxRequests) {
throw new Error("Rate limit exceeded. Please try again in a minute.");
}
hits.push(now);
buckets.set(key, hits);
}

19
lib/api/security.ts Normal file
View File

@@ -0,0 +1,19 @@
import { env } from "@/lib/env";
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
const host =
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (!origin || !host) {
throw new Error("Invalid request origin");
}
const originUrl = new URL(origin);
const appUrl = new URL(env.appUrl);
const allowedHosts = new Set([appUrl.host, host]);
if (!allowedHosts.has(originUrl.host)) {
throw new Error("Cross-site POST blocked");
}
}

116
lib/crypto.ts Normal file
View File

@@ -0,0 +1,116 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from "crypto";
import { env } from "@/lib/env";
const ALGO = "aes-256-gcm";
function deriveKey(secret: string) {
return createHash("sha256").update(secret).digest();
}
export function encryptSecret(value: string) {
if (!env.encryptionKey) {
throw new Error("ENCRYPTION_KEY is not configured");
}
const iv = randomBytes(12);
const key = deriveKey(env.encryptionKey);
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
}
export function decryptSecret(payload: string) {
if (!env.encryptionKey) {
throw new Error("ENCRYPTION_KEY is not configured");
}
const [ivEncoded, tagEncoded, valueEncoded] = payload.split(":");
if (!ivEncoded || !tagEncoded || !valueEncoded) {
throw new Error("Invalid encrypted payload");
}
const key = deriveKey(env.encryptionKey);
const decipher = createDecipheriv(
ALGO,
key,
Buffer.from(ivEncoded, "base64"),
);
decipher.setAuthTag(Buffer.from(tagEncoded, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(valueEncoded, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
function derivePleskKey() {
if (!env.pleskCredEncKey) {
throw new Error("PLESK_CRED_ENC_KEY is not configured");
}
const raw = env.pleskCredEncKey.trim();
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return Buffer.from(raw, "hex");
}
const b64 = Buffer.from(raw, "base64");
if (b64.length === 32) {
return b64;
}
throw new Error("PLESK_CRED_ENC_KEY must be 32-byte base64 or 64-char hex");
}
export function encryptPleskSecret(value: string, keyId = "v1") {
const iv = randomBytes(12);
const key = derivePleskKey();
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return {
encryptedSecret: `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`,
secretKid: keyId,
};
}
export function decryptPleskSecret(payload: string) {
const [ivEncoded, tagEncoded, valueEncoded] = payload.split(":");
if (!ivEncoded || !tagEncoded || !valueEncoded) {
throw new Error("Invalid encrypted payload");
}
const key = derivePleskKey();
const decipher = createDecipheriv(
ALGO,
key,
Buffer.from(ivEncoded, "base64"),
);
decipher.setAuthTag(Buffer.from(tagEncoded, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(valueEncoded, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
}

25
lib/env.ts Normal file
View File

@@ -0,0 +1,25 @@
const requiredEnvs = [
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
] as const;
for (const key of requiredEnvs) {
if (!process.env[key]) {
// Keep as runtime warning for smoother local setup.
// eslint-disable-next-line no-console
console.warn(`[env] Missing ${key}`);
}
}
export const env = {
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
jobSecret: process.env.JOB_SECRET ?? "",
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",
stripePriceId: process.env.STRIPE_PRICE_ID ?? "",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
};

93
lib/plesk.ts Normal file
View File

@@ -0,0 +1,93 @@
import { decryptSecret } from "@/lib/crypto";
export type PleskAuthType = "api_key" | "basic" | "oauth_token";
export type PleskConnection = {
baseUrl: string;
authType: PleskAuthType;
authSecretEncrypted: string;
};
type PleskRequestOptions = {
method?: "GET" | "POST";
body?: unknown;
};
function buildHeaders(authType: PleskAuthType, authSecret: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (authType === "api_key") {
headers["X-API-Key"] = authSecret;
} else if (authType === "oauth_token") {
headers.Authorization = `Bearer ${authSecret}`;
} else {
headers.Authorization = `Basic ${Buffer.from(authSecret).toString("base64")}`;
}
return headers;
}
function joinUrl(baseUrl: string, path: string) {
const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
export async function pleskRequest<T>(
connection: PleskConnection,
path: string,
options: PleskRequestOptions = {},
) {
const authSecret = decryptSecret(connection.authSecretEncrypted);
const response = await fetch(joinUrl(connection.baseUrl, path), {
method: options.method ?? "GET",
headers: buildHeaders(connection.authType, authSecret),
body: options.body ? JSON.stringify(options.body) : undefined,
cache: "no-store",
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Plesk request failed (${response.status}): ${message}`);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null as T;
}
return (await response.json()) as T;
}
export async function validatePleskConnection(connection: PleskConnection) {
try {
await pleskRequest(connection, "/api/v2/server");
return true;
} catch {
await pleskRequest(connection, "/api/v2/subscriptions");
return true;
}
}
export async function listPleskSubscriptions(connection: PleskConnection): Promise<any[]> {
const result = await pleskRequest<any[]>(connection, "/api/v2/subscriptions");
return Array.isArray(result) ? result : [];
}
export async function listPleskDomains(connection: PleskConnection): Promise<any[]> {
const result = await pleskRequest<any[]>(connection, "/api/v2/domains");
return Array.isArray(result) ? result : [];
}
export async function suspendPleskSubscription(connection: PleskConnection, subscriptionId: string) {
return pleskRequest(connection, `/api/v2/subscriptions/${subscriptionId}/suspend`, { method: "POST" });
}
export async function unsuspendPleskSubscription(connection: PleskConnection, subscriptionId: string) {
return pleskRequest(connection, `/api/v2/subscriptions/${subscriptionId}/unsuspend`, {
method: "POST",
});
}

248
lib/plesk/client.ts Normal file
View File

@@ -0,0 +1,248 @@
import { decryptPleskSecret } from "@/lib/crypto";
export type PleskAuthType = "api_key" | "basic";
export type PleskConnection = {
baseUrl: string;
authType: PleskAuthType;
encryptedSecret: string;
};
export type PleskSubscription = {
id?: string | number;
guid?: string;
subscription_id?: string | number;
external_id?: string;
name?: string;
status?: string;
owner_login?: string;
owner?: { login?: string };
plan_name?: string;
service_plan?: { name?: string };
};
export type PleskDomain = {
id?: string | number;
guid?: string;
name?: string;
domain_name?: string;
domain?: string;
status?: string;
subscription?: { id?: string | number };
subscription_id?: string | number;
webspace_id?: string | number;
};
type PleskCliResult = {
code?: number;
stdout?: string;
stderr?: string;
};
const DEFAULT_TIMEOUT_MS = 12_000;
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.trim().replace(/\/+$/, "");
}
function buildHeaders(
authType: PleskAuthType,
plainSecret: string,
): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (authType === "api_key") {
headers["X-API-Key"] = plainSecret;
} else {
headers.Authorization = `Basic ${Buffer.from(plainSecret).toString("base64")}`;
}
return headers;
}
async function withTimeout(
input: RequestInfo | URL,
init: RequestInit,
timeoutMs = DEFAULT_TIMEOUT_MS,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
...init,
signal: controller.signal,
cache: "no-store",
});
} finally {
clearTimeout(timeout);
}
}
export async function pleskRequest<T>(
connection: PleskConnection,
path: string,
init: RequestInit = {},
retries = 1,
) {
const base = normalizeBaseUrl(connection.baseUrl);
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
const secret = decryptPleskSecret(connection.encryptedSecret);
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const res = await withTimeout(url, {
...init,
headers: {
...buildHeaders(connection.authType, secret),
...(init.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Plesk request failed (${res.status}): ${text || "unknown error"}`,
);
}
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null as T;
}
return (await res.json()) as T;
} catch (error) {
lastError =
error instanceof Error ? error : new Error("Plesk request failed");
if (attempt === retries) break;
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
}
}
throw lastError ?? new Error("Plesk request failed");
}
export async function testPleskConnection(connection: PleskConnection) {
try {
await pleskRequest(connection, "/api/v2/server");
return { ok: true as const };
} catch {
await pleskRequest(connection, "/api/v2/subscriptions?limit=1");
return { ok: true as const };
}
}
export async function listPleskSubscriptions(connection: PleskConnection) {
try {
const all: PleskSubscription[] = [];
for (let page = 1; page <= 20; page += 1) {
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/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const columns = line.split(/\s+/);
const externalId = columns[0] ?? line;
return {
id: externalId,
subscription_id: externalId,
external_id: externalId,
name: columns.slice(1).join(" ") || externalId,
status: null,
};
});
}
}
export async function pleskCliCall(
connection: PleskConnection,
command: string,
params: string[],
) {
const payload = await pleskRequest<PleskCliResult>(
connection,
`/api/v2/cli/${command}/call`,
{
method: "POST",
body: JSON.stringify({ params }),
},
);
const code = payload?.code ?? -1;
const stdout = payload?.stdout ?? "";
const stderr = payload?.stderr ?? "";
if (code !== 0) {
throw new Error(
`Plesk CLI '${command}' failed (code ${code}): ${stderr || "unknown error"}${stdout ? `\n${stdout}` : ""}`,
);
}
return { code, stdout, stderr };
}
export async function listPleskDomains(connection: PleskConnection) {
const all: PleskDomain[] = [];
for (let page = 1; page <= 20; page += 1) {
const payload = await pleskRequest<PleskDomain[]>(
connection,
`/api/v2/domains?page=${page}&per_page=100`,
);
const rows = Array.isArray(payload) ? payload : [];
all.push(...rows);
if (rows.length < 100) break;
}
return all;
}
export async function suspendPleskSubscription(
connection: PleskConnection,
subscriptionName: string,
) {
return pleskCliCall(connection, "subscription", [
"--webspace-off",
subscriptionName,
]);
}
export async function unsuspendPleskSubscription(
connection: PleskConnection,
subscriptionName: string,
) {
return pleskCliCall(connection, "subscription", [
"--webspace-on",
subscriptionName,
]);
}

218
lib/plesk/sync.ts Normal file
View File

@@ -0,0 +1,218 @@
import type { Database } from "@/lib/types";
import { listPleskDomains, listPleskSubscriptions } from "@/lib/plesk/client";
type SupabaseLike = any;
type SyncResult = {
instanceId: string;
agencyId: string;
skipped: boolean;
reason?: string;
subscriptionsUpserted: number;
domainsUpserted: number;
};
type InstanceRow = Pick<
Database["public"]["Tables"]["plesk_instances"]["Row"],
| "id"
| "agency_id"
| "base_url"
| "auth_type"
| "encrypted_secret"
| "last_connected_at"
>;
const MIN_INTERVAL_MS = 3 * 60 * 1000;
export async function syncPleskInstance(
supabase: SupabaseLike,
instance: InstanceRow,
actorUserId?: string | null,
action = "sync",
): Promise<SyncResult> {
const startedAtIso = new Date().toISOString();
await supabase
.from("plesk_instances")
.update({
status: "syncing",
last_sync_error: null,
})
.eq("id", instance.id)
.eq("agency_id", instance.agency_id);
const last = instance.last_connected_at
? new Date(instance.last_connected_at).getTime()
: 0;
if (last && Date.now() - last < MIN_INTERVAL_MS) {
await supabase
.from("plesk_instances")
.update({
status: "connected",
last_sync_at: startedAtIso,
last_sync_status: "skipped",
last_sync_error: "recently_synced",
})
.eq("id", instance.id)
.eq("agency_id", instance.agency_id);
return {
instanceId: instance.id,
agencyId: instance.agency_id,
skipped: true,
reason: "recently_synced",
subscriptionsUpserted: 0,
domainsUpserted: 0,
};
}
try {
const [subscriptionsRaw, domainsRaw] = await Promise.all([
listPleskSubscriptions({
baseUrl: instance.base_url,
authType: instance.auth_type,
encryptedSecret: instance.encrypted_secret,
}),
listPleskDomains({
baseUrl: instance.base_url,
authType: instance.auth_type,
encryptedSecret: instance.encrypted_secret,
}),
]);
const subscriptionsForUpsert = subscriptionsRaw
.map((item: any) => {
const sourceId = item?.id ?? item?.guid ?? item?.subscription_id;
if (!sourceId) return null;
return {
agency_id: instance.agency_id,
plesk_instance_id: instance.id,
plesk_subscription_id: String(sourceId),
name: item?.name ? String(item.name) : null,
status: item?.status ? String(item.status) : null,
owner_login: item?.owner_login
? String(item.owner_login)
: item?.owner?.login
? String(item.owner.login)
: null,
plan_name: item?.plan_name
? String(item.plan_name)
: item?.service_plan?.name
? String(item.service_plan.name)
: null,
};
})
.filter(Boolean) as Array<Record<string, unknown>>;
if (subscriptionsForUpsert.length > 0) {
const { error } = await supabase
.from("plesk_subscriptions")
.upsert(subscriptionsForUpsert, {
onConflict: "agency_id,plesk_subscription_id",
});
if (error) throw new Error(error.message);
}
const domainsForUpsert = domainsRaw
.map((item: any) => {
const domainId = item?.id ?? item?.guid ?? item?.name;
const domainName = item?.name ?? item?.domain_name ?? item?.domain;
const externalSubscriptionId =
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
const rawCreated = item?.created_at ?? item?.created;
const sourceCreatedAt = rawCreated
? new Date(String(rawCreated)).toISOString()
: null;
const aliasesCount =
typeof item?.aliases_count === "number"
? item.aliases_count
: Array.isArray(item?.aliases)
? item.aliases.length
: null;
if (!domainId || !domainName) return null;
return {
agency_id: instance.agency_id,
plesk_instance_id: instance.id,
subscription_id: null,
external_subscription_id: externalSubscriptionId
? String(externalSubscriptionId)
: null,
plesk_domain_id: String(domainId),
domain_name: String(domainName),
status: item?.status ? String(item.status) : null,
hosting_type: item?.hosting_type
? String(item.hosting_type)
: item?.hosting?.type
? String(item.hosting.type)
: null,
source_created_at: sourceCreatedAt,
aliases_count: aliasesCount,
};
})
.filter(Boolean) as Array<Record<string, unknown>>;
if (domainsForUpsert.length > 0) {
const { error } = await supabase
.from("plesk_domains")
.upsert(domainsForUpsert, {
onConflict: "agency_id,plesk_domain_id",
});
if (error) throw new Error(error.message);
}
await supabase
.from("plesk_instances")
.update({
last_connected_at: new Date().toISOString(),
status: "connected",
error_message: null,
last_sync_at: startedAtIso,
last_sync_status: "success",
last_sync_error: null,
last_sync_subscriptions: subscriptionsForUpsert.length,
last_sync_domains: domainsForUpsert.length,
})
.eq("id", instance.id)
.eq("agency_id", instance.agency_id);
await supabase.from("actions_log").insert({
agency_id: instance.agency_id,
actor_user_id: actorUserId ?? null,
target_type: "plesk_instance",
target_id: instance.id,
action,
status: "success",
metadata: {
subscriptions_seen: subscriptionsRaw.length,
domains_seen: domainsRaw.length,
},
});
return {
instanceId: instance.id,
agencyId: instance.agency_id,
skipped: false,
subscriptionsUpserted: subscriptionsForUpsert.length,
domainsUpserted: domainsForUpsert.length,
};
} catch (error) {
const message =
error instanceof Error ? error.message.slice(0, 1000) : "Sync failed";
await supabase
.from("plesk_instances")
.update({
status: "error",
error_message: message,
last_sync_at: startedAtIso,
last_sync_status: "failed",
last_sync_error: message,
})
.eq("id", instance.id)
.eq("agency_id", instance.agency_id);
throw error;
}
}

19
lib/stripe.ts Normal file
View File

@@ -0,0 +1,19 @@
import Stripe from "stripe";
import { env } from "@/lib/env";
let stripeClient: Stripe | null = null;
export function getStripeClient() {
if (!env.stripeSecretKey) {
throw new Error("STRIPE_SECRET_KEY is not configured");
}
if (!stripeClient) {
stripeClient = new Stripe(env.stripeSecretKey, {
apiVersion: "2025-02-24.acacia",
});
}
return stripeClient;
}

13
lib/supabase/admin.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createClient } from "@supabase/supabase-js";
import { env } from "@/lib/env";
import type { Database } from "@/lib/types";
export function createSupabaseAdminClient() {
return createClient<Database>(env.supabaseUrl, env.supabaseServiceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}

9
lib/supabase/browser.ts Normal file
View File

@@ -0,0 +1,9 @@
"use client";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import type { Database } from "@/lib/types";
export function createSupabaseBrowserClient() {
return createClientComponentClient<Database>();
}

8
lib/supabase/route.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import type { Database } from "@/lib/types";
export function createSupabaseRouteClient() {
return createRouteHandlerClient<Database>({ cookies });
}

8
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createServerComponentClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import type { Database } from "@/lib/types";
export function createSupabaseServerClient() {
return createServerComponentClient<Database>({ cookies });
}

311
lib/types.ts Normal file
View File

@@ -0,0 +1,311 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json }
| Json[];
export type Database = {
public: {
Tables: {
agencies: {
Row: { id: string; name: string; created_at: string };
Insert: { id?: string; name: string; created_at?: string };
Update: { id?: string; name?: string; created_at?: string };
};
agency_members: {
Row: {
id: string;
agency_id: string;
user_id: string;
role: "owner" | "admin" | "member";
created_at: string;
};
Insert: {
id?: string;
agency_id: string;
user_id: string;
role: "owner" | "admin" | "member";
created_at?: string;
};
Update: {
id?: string;
agency_id?: string;
user_id?: string;
role?: "owner" | "admin" | "member";
created_at?: string;
};
};
clients: {
Row: {
id: string;
agency_id: string;
name: string;
email: string | null;
phone: string | null;
notes: string | null;
created_at: string;
};
Insert: {
id?: string;
agency_id: string;
name: string;
email?: string | null;
phone?: string | null;
notes?: string | null;
created_at?: string;
};
Update: {
id?: string;
agency_id?: string;
name?: string;
email?: string | null;
phone?: string | null;
notes?: string | null;
created_at?: string;
};
};
plesk_instances: {
Row: {
id: string;
agency_id: string;
name: string;
base_url: string;
auth_type: "api_key" | "basic";
encrypted_secret: string;
secret_kid: string | null;
status: "connected" | "error" | "disabled" | "syncing";
error_message: string | null;
last_connected_at: string | null;
last_sync_at: string | null;
last_sync_status: string | null;
last_sync_error: string | null;
last_sync_subscriptions: number;
last_sync_domains: number;
created_at: string;
};
Insert: {
id?: string;
agency_id: string;
name: string;
base_url: string;
auth_type: "api_key" | "basic";
encrypted_secret: string;
secret_kid?: string | null;
status?: "connected" | "error" | "disabled" | "syncing";
error_message?: string | null;
last_connected_at?: string | null;
last_sync_at?: string | null;
last_sync_status?: string | null;
last_sync_error?: string | null;
last_sync_subscriptions?: number;
last_sync_domains?: number;
created_at?: string;
};
Update: {
id?: string;
agency_id?: string;
name?: string;
base_url?: string;
auth_type?: "api_key" | "basic";
encrypted_secret?: string;
secret_kid?: string | null;
status?: "connected" | "error" | "disabled" | "syncing";
error_message?: string | null;
last_connected_at?: string | null;
last_sync_at?: string | null;
last_sync_status?: string | null;
last_sync_error?: string | null;
last_sync_subscriptions?: number;
last_sync_domains?: number;
created_at?: string;
};
};
plesk_subscriptions: {
Row: {
id: string;
agency_id: string;
plesk_instance_id: string;
client_id: string | null;
plesk_subscription_id: string;
name: string | null;
status: string | null;
owner_login: string | null;
plan_name: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
agency_id: string;
plesk_instance_id: string;
client_id?: string | null;
plesk_subscription_id: string;
name?: string | null;
status?: string | null;
owner_login?: string | null;
plan_name?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
agency_id?: string;
plesk_instance_id?: string;
client_id?: string | null;
plesk_subscription_id?: string;
name?: string | null;
status?: string | null;
owner_login?: string | null;
plan_name?: string | null;
created_at?: string;
updated_at?: string;
};
};
plesk_domains: {
Row: {
id: string;
agency_id: string;
plesk_instance_id: string;
subscription_id: string | null;
external_subscription_id: string | null;
plesk_domain_id: string;
domain_name: string;
status: string | null;
hosting_type: string | null;
source_created_at: string | null;
aliases_count: number | null;
updated_at: string;
created_at: string;
};
Insert: {
id?: string;
agency_id: string;
plesk_instance_id: string;
subscription_id?: string | null;
external_subscription_id?: string | null;
plesk_domain_id: string;
domain_name: string;
status?: string | null;
hosting_type?: string | null;
source_created_at?: string | null;
aliases_count?: number | null;
updated_at?: string;
created_at?: string;
};
Update: {
id?: string;
agency_id?: string;
plesk_instance_id?: string;
subscription_id?: string | null;
external_subscription_id?: string | null;
plesk_domain_id?: string;
domain_name?: string;
status?: string | null;
hosting_type?: string | null;
source_created_at?: string | null;
aliases_count?: number | null;
updated_at?: string;
created_at?: string;
};
};
actions_log: {
Row: {
id: string;
agency_id: string;
actor_user_id: string | null;
target_type: string;
target_id: string | null;
action: string;
status: "pending" | "success" | "failed";
error_message: string | null;
metadata: Json;
created_at: string;
};
Insert: {
id?: string;
agency_id: string;
actor_user_id?: string | null;
target_type: string;
target_id?: string | null;
action: string;
status?: "pending" | "success" | "failed";
error_message?: string | null;
metadata?: Json;
created_at?: string;
};
Update: {
id?: string;
agency_id?: string;
actor_user_id?: string | null;
target_type?: string;
target_id?: string | null;
action?: string;
status?: "pending" | "success" | "failed";
error_message?: string | null;
metadata?: Json;
created_at?: string;
};
};
billing_accounts: {
Row: {
id: string;
agency_id: string;
stripe_customer_id: string | null;
stripe_subscription_id: string | null;
plan_id: string | null;
subscription_status: string | null;
current_period_end: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
agency_id: string;
stripe_customer_id?: string | null;
stripe_subscription_id?: string | null;
plan_id?: string | null;
subscription_status?: string | null;
current_period_end?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
agency_id?: string;
stripe_customer_id?: string | null;
stripe_subscription_id?: string | null;
plan_id?: string | null;
subscription_status?: string | null;
current_period_end?: string | null;
created_at?: string;
updated_at?: string;
};
};
entitlements: {
Row: {
id: string;
agency_id: string;
key: string;
value: Json;
created_at: string;
};
Insert: {
id?: string;
agency_id: string;
key: string;
value?: Json;
created_at?: string;
};
Update: {
id?: string;
agency_id?: string;
key?: string;
value?: Json;
created_at?: string;
};
};
};
};
};

15
middleware.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const supabase = createMiddlewareClient({ req, res });
await supabase.auth.getSession();
return res;
}
export const config = {
matcher: ["/dashboard/:path*", "/auth/callback", "/api/((?!stripe/webhook).*)"],
};

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

10
next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "2mb",
},
},
};
export default nextConfig;

5927
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "plesk-agency-portal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/supabase-js": "^2.49.8",
"clsx": "^2.1.1",
"next": "^14.2.30",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"stripe": "^18.3.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^20.17.28",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.30",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,375 @@
-- CL2: Multi-tenant RLS hardening + onboarding-safe policies
create extension if not exists pgcrypto;
-- ---------------------------------------------------------------------------
-- Helper functions
-- ---------------------------------------------------------------------------
create or replace function public.is_agency_member(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
);
$$;
create or replace function public.is_agency_admin(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
and am.role in ('owner', 'admin')
);
$$;
-- ---------------------------------------------------------------------------
-- RLS enablement
-- ---------------------------------------------------------------------------
alter table public.agencies enable row level security;
alter table public.agency_members enable row level security;
alter table public.clients enable row level security;
alter table public.plesk_instances enable row level security;
alter table public.plesk_subscriptions enable row level security;
alter table public.plesk_domains enable row level security;
alter table public.actions_log enable row level security;
alter table public.billing_accounts enable row level security;
alter table public.entitlements enable row level security;
-- ---------------------------------------------------------------------------
-- Drop legacy policies (if present)
-- ---------------------------------------------------------------------------
drop policy if exists agencies_select on public.agencies;
drop policy if exists agencies_update on public.agencies;
drop policy if exists agency_members_select on public.agency_members;
drop policy if exists agency_members_manage on public.agency_members;
drop policy if exists clients_member_access on public.clients;
drop policy if exists plesk_instances_member_access on public.plesk_instances;
drop policy if exists plesk_subscriptions_member_access on public.plesk_subscriptions;
drop policy if exists plesk_domains_member_access on public.plesk_domains;
drop policy if exists actions_log_member_access on public.actions_log;
drop policy if exists billing_accounts_member_access on public.billing_accounts;
drop policy if exists entitlements_member_access on public.entitlements;
-- ---------------------------------------------------------------------------
-- agencies
-- ---------------------------------------------------------------------------
create policy agencies_select_member
on public.agencies
for select
to authenticated
using (public.is_agency_member(id));
create policy agencies_insert_authenticated
on public.agencies
for insert
to authenticated
with check (true);
create policy agencies_update_admin
on public.agencies
for update
to authenticated
using (public.is_agency_admin(id))
with check (public.is_agency_admin(id));
create policy agencies_delete_admin
on public.agencies
for delete
to authenticated
using (public.is_agency_admin(id));
-- ---------------------------------------------------------------------------
-- agency_members
-- ---------------------------------------------------------------------------
create policy agency_members_select_self
on public.agency_members
for select
to authenticated
using (user_id = auth.uid());
-- Onboarding: user can create their own owner membership for their first agency.
create policy agency_members_insert_self_onboarding
on public.agency_members
for insert
to authenticated
with check (
user_id = auth.uid()
and role = 'owner'
and not exists (
select 1
from public.agency_members am
where am.user_id = auth.uid()
)
);
-- Admins can add members to their agency.
create policy agency_members_insert_admin
on public.agency_members
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy agency_members_update_admin
on public.agency_members
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy agency_members_delete_admin_or_self
on public.agency_members
for delete
to authenticated
using (public.is_agency_admin(agency_id) or user_id = auth.uid());
-- ---------------------------------------------------------------------------
-- clients (member writable by design)
-- ---------------------------------------------------------------------------
create policy clients_select_member
on public.clients
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy clients_insert_member
on public.clients
for insert
to authenticated
with check (public.is_agency_member(agency_id));
create policy clients_update_member
on public.clients
for update
to authenticated
using (public.is_agency_member(agency_id))
with check (public.is_agency_member(agency_id));
create policy clients_delete_member
on public.clients
for delete
to authenticated
using (public.is_agency_member(agency_id));
-- ---------------------------------------------------------------------------
-- plesk_instances (admin-writable)
-- ---------------------------------------------------------------------------
create policy plesk_instances_select_member
on public.plesk_instances
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy plesk_instances_insert_admin
on public.plesk_instances
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy plesk_instances_update_admin
on public.plesk_instances
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy plesk_instances_delete_admin
on public.plesk_instances
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- plesk_subscriptions (admin-writable synced data)
-- ---------------------------------------------------------------------------
create policy plesk_subscriptions_select_member
on public.plesk_subscriptions
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy plesk_subscriptions_insert_admin
on public.plesk_subscriptions
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy plesk_subscriptions_update_admin
on public.plesk_subscriptions
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy plesk_subscriptions_delete_admin
on public.plesk_subscriptions
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- plesk_domains (admin-writable synced data)
-- ---------------------------------------------------------------------------
create policy plesk_domains_select_member
on public.plesk_domains
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy plesk_domains_insert_admin
on public.plesk_domains
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy plesk_domains_update_admin
on public.plesk_domains
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy plesk_domains_delete_admin
on public.plesk_domains
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- actions_log
-- ---------------------------------------------------------------------------
create policy actions_log_select_member
on public.actions_log
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy actions_log_insert_admin
on public.actions_log
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy actions_log_update_admin
on public.actions_log
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- billing_accounts (admin-writable)
-- ---------------------------------------------------------------------------
create policy billing_accounts_select_member
on public.billing_accounts
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy billing_accounts_insert_admin
on public.billing_accounts
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy billing_accounts_update_admin
on public.billing_accounts
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy billing_accounts_delete_admin
on public.billing_accounts
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- entitlements (admin-writable)
-- ---------------------------------------------------------------------------
create policy entitlements_select_member
on public.entitlements
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy entitlements_insert_admin
on public.entitlements
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy entitlements_update_admin
on public.entitlements
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy entitlements_delete_admin
on public.entitlements
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- Indexes (if missing)
-- ---------------------------------------------------------------------------
create index if not exists idx_agency_members_agency_id on public.agency_members (agency_id);
create index if not exists idx_agency_members_user_id on public.agency_members (user_id);
create index if not exists idx_clients_agency_id on public.clients (agency_id);
create index if not exists idx_plesk_instances_agency_id on public.plesk_instances (agency_id);
create index if not exists idx_plesk_subscriptions_agency_id on public.plesk_subscriptions (agency_id);
create index if not exists idx_plesk_subscriptions_instance_id on public.plesk_subscriptions (plesk_instance_id);
create index if not exists idx_plesk_subscriptions_client_id on public.plesk_subscriptions (client_id);
create index if not exists idx_plesk_domains_agency_id on public.plesk_domains (agency_id);
create index if not exists idx_plesk_domains_instance_id on public.plesk_domains (plesk_instance_id);
create index if not exists idx_plesk_domains_subscription_id on public.plesk_domains (subscription_id);
create index if not exists idx_actions_log_agency_id on public.actions_log (agency_id);
create index if not exists idx_actions_log_target_id on public.actions_log (target_id);
create index if not exists idx_billing_accounts_agency_id on public.billing_accounts (agency_id);
create index if not exists idx_entitlements_agency_id on public.entitlements (agency_id);
-- ---------------------------------------------------------------------------
-- updated_at trigger safety for expected tables
-- ---------------------------------------------------------------------------
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_plesk_subscriptions_updated_at on public.plesk_subscriptions;
create trigger trg_plesk_subscriptions_updated_at
before update on public.plesk_subscriptions
for each row execute function public.set_updated_at();
drop trigger if exists trg_billing_accounts_updated_at on public.billing_accounts;
create trigger trg_billing_accounts_updated_at
before update on public.billing_accounts
for each row execute function public.set_updated_at();

View File

@@ -0,0 +1,49 @@
-- CL3.5: background auto-sync lock primitives
create table if not exists public.job_locks (
job_name text primary key,
locked_at timestamptz not null default now(),
locked_by text,
expires_at timestamptz not null
);
create or replace function public.acquire_job_lock(
p_job_name text,
p_locked_by text,
p_ttl_seconds integer default 240
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_acquired boolean := false;
begin
insert into public.job_locks (job_name, locked_by, locked_at, expires_at)
values (
p_job_name,
p_locked_by,
now(),
now() + make_interval(secs => p_ttl_seconds)
)
on conflict (job_name)
do update
set locked_by = excluded.locked_by,
locked_at = excluded.locked_at,
expires_at = excluded.expires_at
where public.job_locks.expires_at < now();
get diagnostics v_acquired = row_count;
return v_acquired;
end;
$$;
create or replace function public.release_job_lock(p_job_name text)
returns void
language sql
security definer
set search_path = public
as $$
delete from public.job_locks where job_name = p_job_name;
$$;

View File

@@ -0,0 +1,37 @@
-- CL3.6a: Instance health + domains visibility
alter table public.plesk_instances
add column if not exists last_sync_at timestamptz,
add column if not exists last_sync_status text,
add column if not exists last_sync_error text,
add column if not exists last_sync_subscriptions integer not null default 0,
add column if not exists last_sync_domains integer not null default 0;
update public.plesk_instances
set
last_sync_subscriptions = coalesce(last_sync_subscriptions, 0),
last_sync_domains = coalesce(last_sync_domains, 0);
alter table public.plesk_instances
alter column last_sync_subscriptions set default 0,
alter column last_sync_domains set default 0,
alter column last_sync_subscriptions set not null,
alter column last_sync_domains set not null;
alter table public.plesk_instances
drop constraint if exists plesk_instances_status_check;
alter table public.plesk_instances
add constraint plesk_instances_status_check
check (status in ('connected', 'error', 'disabled', 'syncing'));
alter table public.plesk_domains
add column if not exists hosting_type text,
add column if not exists source_created_at timestamptz,
add column if not exists aliases_count integer,
add column if not exists updated_at timestamptz not null default now();
drop trigger if exists trg_plesk_domains_updated_at on public.plesk_domains;
create trigger trg_plesk_domains_updated_at
before update on public.plesk_domains
for each row execute function public.set_updated_at();

View File

@@ -0,0 +1,7 @@
-- CL3 Patch: allow domain sync without strict subscription mapping
alter table public.plesk_domains
alter column subscription_id drop not null;
alter table public.plesk_domains
add column if not exists external_subscription_id text;

View File

@@ -0,0 +1,106 @@
-- CL3: Plesk connection + sync MVP hardening
-- 1) plesk_instances credential/storage hardening
alter table public.plesk_instances
add column if not exists encrypted_secret text,
add column if not exists secret_kid text,
add column if not exists error_message text;
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'plesk_instances'
and column_name = 'auth_secret_encrypted'
) then
update public.plesk_instances
set encrypted_secret = coalesce(encrypted_secret, auth_secret_encrypted)
where encrypted_secret is null;
alter table public.plesk_instances
drop column auth_secret_encrypted;
end if;
end $$;
update public.plesk_instances
set status = 'connected'
where status = 'active';
alter table public.plesk_instances
alter column encrypted_secret set not null,
alter column status set default 'error';
alter table public.plesk_instances
drop constraint if exists plesk_instances_auth_type_check;
alter table public.plesk_instances
add constraint plesk_instances_auth_type_check
check (auth_type in ('api_key', 'basic'));
alter table public.plesk_instances
drop constraint if exists plesk_instances_status_check;
alter table public.plesk_instances
add constraint plesk_instances_status_check
check (status in ('connected', 'error', 'disabled'));
-- 2) FK guarantees
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'plesk_instances_agency_id_fkey'
) then
alter table public.plesk_instances
add constraint plesk_instances_agency_id_fkey
foreign key (agency_id) references public.agencies(id) on delete cascade;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'plesk_subscriptions_plesk_instance_id_fkey'
) then
alter table public.plesk_subscriptions
add constraint plesk_subscriptions_plesk_instance_id_fkey
foreign key (plesk_instance_id) references public.plesk_instances(id) on delete cascade;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'plesk_domains_subscription_id_fkey'
) then
alter table public.plesk_domains
add constraint plesk_domains_subscription_id_fkey
foreign key (subscription_id) references public.plesk_subscriptions(id) on delete cascade;
end if;
end $$;
-- 3) Uniques + indexes
create unique index if not exists uq_plesk_instances_agency_base_url
on public.plesk_instances (agency_id, base_url);
create unique index if not exists uq_plesk_subscriptions_agency_external
on public.plesk_subscriptions (agency_id, plesk_subscription_id);
create unique index if not exists uq_plesk_domains_agency_external
on public.plesk_domains (agency_id, plesk_domain_id);
create index if not exists idx_plesk_instances_agency_id
on public.plesk_instances (agency_id);
create index if not exists idx_plesk_subscriptions_agency_id
on public.plesk_subscriptions (agency_id);
create index if not exists idx_plesk_subscriptions_instance_id
on public.plesk_subscriptions (plesk_instance_id);
create index if not exists idx_plesk_domains_agency_id
on public.plesk_domains (agency_id);
create index if not exists idx_plesk_domains_instance_id
on public.plesk_domains (plesk_instance_id);
create index if not exists idx_plesk_domains_subscription_id
on public.plesk_domains (subscription_id);

225
supabase/schema.sql Normal file
View File

@@ -0,0 +1,225 @@
-- Plesk Agency Portal MVP schema
-- Run this in Supabase SQL editor.
create extension if not exists pgcrypto;
create table if not exists agencies (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now()
);
create table if not exists agency_members (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
user_id uuid not null,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
unique (agency_id, user_id)
);
create table if not exists clients (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
name text not null,
email text,
phone text,
notes text,
created_at timestamptz not null default now()
);
create table if not exists plesk_instances (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
name text not null,
base_url text not null,
auth_type text not null check (auth_type in ('api_key', 'basic', 'oauth_token')),
auth_secret_encrypted text not null,
status text not null default 'active' check (status in ('active', 'disabled')),
last_connected_at timestamptz,
created_at timestamptz not null default now()
);
create table if not exists plesk_subscriptions (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
plesk_instance_id uuid not null references plesk_instances(id) on delete cascade,
client_id uuid references clients(id) on delete set null,
plesk_subscription_id text not null,
name text,
status text,
owner_login text,
plan_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (plesk_instance_id, plesk_subscription_id)
);
create table if not exists plesk_domains (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
plesk_instance_id uuid not null references plesk_instances(id) on delete cascade,
subscription_id uuid not null references plesk_subscriptions(id) on delete cascade,
plesk_domain_id text not null,
domain_name text not null,
status text,
created_at timestamptz not null default now(),
unique (plesk_instance_id, plesk_domain_id),
unique (plesk_instance_id, domain_name)
);
create table if not exists actions_log (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
actor_user_id uuid,
target_type text not null,
target_id uuid,
action text not null,
status text not null default 'pending' check (status in ('pending', 'success', 'failed')),
error_message text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists billing_accounts (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
stripe_customer_id text,
stripe_subscription_id text,
plan_id text,
subscription_status text,
current_period_end timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (agency_id),
unique (stripe_customer_id)
);
create table if not exists entitlements (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
key text not null,
value jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (agency_id, key)
);
create or replace function set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_plesk_subscriptions_updated_at on plesk_subscriptions;
create trigger trg_plesk_subscriptions_updated_at
before update on plesk_subscriptions
for each row execute function set_updated_at();
drop trigger if exists trg_billing_accounts_updated_at on billing_accounts;
create trigger trg_billing_accounts_updated_at
before update on billing_accounts
for each row execute function set_updated_at();
create or replace function is_agency_member(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
);
$$;
create or replace function is_agency_admin(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
and am.role in ('owner', 'admin')
);
$$;
alter table agencies enable row level security;
alter table agency_members enable row level security;
alter table clients enable row level security;
alter table plesk_instances enable row level security;
alter table plesk_subscriptions enable row level security;
alter table plesk_domains enable row level security;
alter table actions_log enable row level security;
alter table billing_accounts enable row level security;
alter table entitlements enable row level security;
drop policy if exists agencies_select on agencies;
create policy agencies_select on agencies
for select using (is_agency_member(id));
drop policy if exists agencies_update on agencies;
create policy agencies_update on agencies
for update using (is_agency_admin(id));
drop policy if exists agency_members_select on agency_members;
create policy agency_members_select on agency_members
for select using (is_agency_member(agency_id));
drop policy if exists agency_members_manage on agency_members;
create policy agency_members_manage on agency_members
for all using (is_agency_admin(agency_id))
with check (is_agency_admin(agency_id));
drop policy if exists clients_member_access on clients;
create policy clients_member_access on clients
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists plesk_instances_member_access on plesk_instances;
create policy plesk_instances_member_access on plesk_instances
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists plesk_subscriptions_member_access on plesk_subscriptions;
create policy plesk_subscriptions_member_access on plesk_subscriptions
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists plesk_domains_member_access on plesk_domains;
create policy plesk_domains_member_access on plesk_domains
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists actions_log_member_access on actions_log;
create policy actions_log_member_access on actions_log
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists billing_accounts_member_access on billing_accounts;
create policy billing_accounts_member_access on billing_accounts
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists entitlements_member_access on entitlements;
create policy entitlements_member_access on entitlements
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));

23
tailwind.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
50: "#f5f7ff",
500: "#4f46e5",
600: "#4338ca",
},
},
},
},
plugins: [],
};
export default config;

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long