Initial working state: CL3.5 complete (Plesk sync + suspend/unsuspend)
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
36
.vscode/launch.json
vendored
Normal 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
99
README.md
Normal 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
49
SECURITY.md
Normal 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()`.
|
||||
108
app/api/jobs/plesk-sync-all/route.ts
Normal file
108
app/api/jobs/plesk-sync-all/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
52
app/api/plesk/instances/[id]/sync/route.ts
Normal file
52
app/api/plesk/instances/[id]/sync/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
85
app/api/plesk/instances/[id]/test/route.ts
Normal file
85
app/api/plesk/instances/[id]/test/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
122
app/api/plesk/instances/route.ts
Normal file
122
app/api/plesk/instances/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
133
app/api/plesk/subscriptions/[id]/action/route.ts
Normal file
133
app/api/plesk/subscriptions/[id]/action/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
60
app/api/plesk/sync/route.ts
Normal file
60
app/api/plesk/sync/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
69
app/api/stripe/checkout/route.ts
Normal file
69
app/api/stripe/checkout/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/stripe/portal/route.ts
Normal file
39
app/api/stripe/portal/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/api/stripe/webhook/route.ts
Normal file
126
app/api/stripe/webhook/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/auth/callback/route.ts
Normal file
32
app/auth/callback/route.ts
Normal 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
163
app/dashboard/page.tsx
Normal 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
12
app/globals.css
Normal 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
18
app/layout.tsx
Normal 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
22
app/login/page.tsx
Normal 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
24
app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
components/auth/login-form.tsx
Normal file
65
components/auth/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/auth/sign-out-button.tsx
Normal file
26
components/auth/sign-out-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
472
components/dashboard/dashboard-controls.tsx
Normal file
472
components/dashboard/dashboard-controls.tsx
Normal 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
139
lib/agency.ts
Normal 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
18
lib/api/rate-limit.ts
Normal 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
19
lib/api/security.ts
Normal 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
116
lib/crypto.ts
Normal 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
25
lib/env.ts
Normal 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
93
lib/plesk.ts
Normal 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
248
lib/plesk/client.ts
Normal 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
218
lib/plesk/sync.ts
Normal 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
19
lib/stripe.ts
Normal 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
13
lib/supabase/admin.ts
Normal 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
9
lib/supabase/browser.ts
Normal 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
8
lib/supabase/route.ts
Normal 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
8
lib/supabase/server.ts
Normal 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
311
lib/types.ts
Normal 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
15
middleware.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
10
next.config.mjs
Normal 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
5927
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
375
supabase/migrations/CL2_rls_policies.sql
Normal file
375
supabase/migrations/CL2_rls_policies.sql
Normal 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();
|
||||
49
supabase/migrations/CL35_auto_sync_job_locks.sql
Normal file
49
supabase/migrations/CL35_auto_sync_job_locks.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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();
|
||||
7
supabase/migrations/CL3_patch_domains_nullable.sql
Normal file
7
supabase/migrations/CL3_patch_domains_nullable.sql
Normal 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;
|
||||
106
supabase/migrations/CL3_plesk_connection_sync.sql
Normal file
106
supabase/migrations/CL3_plesk_connection_sync.sql
Normal 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
225
supabase/schema.sql
Normal 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
23
tailwind.config.ts
Normal 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
24
tsconfig.json
Normal 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
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user