feat(v2): full spec implementation — root SSH, bootstrap sentinel, HMAC webhook, all parametric routes fixed #1
@@ -13,7 +13,7 @@ import {
|
|||||||
deleteKeyPairAws,
|
deleteKeyPairAws,
|
||||||
deleteSecurityGroupAws,
|
deleteSecurityGroupAws,
|
||||||
} from "./ec2";
|
} from "./ec2";
|
||||||
import { connectSsh, type SshSession } from "./ssh";
|
import { connectSsh, waitForBootstrap, type SshSession } from "./ssh";
|
||||||
import {
|
import {
|
||||||
buildPrCommentBody,
|
buildPrCommentBody,
|
||||||
postComment,
|
postComment,
|
||||||
@@ -70,29 +70,6 @@ async function updateStatus(previewId: number, status: Preview["status"], extra:
|
|||||||
await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
|
await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) {
|
|
||||||
const body = buildPrCommentBody({
|
|
||||||
owner: repoConfig.repoOwner,
|
|
||||||
repo: repoConfig.repoName,
|
|
||||||
prNumber: preview.prNumber,
|
|
||||||
status: statusLine,
|
|
||||||
commitSha: preview.commitSha,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
ppBaseUrl: env.PP_BASE_URL,
|
|
||||||
lastLogLines,
|
|
||||||
instanceIp: preview.instanceIp ?? undefined,
|
|
||||||
port: preview.port,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (preview.giteaCommentId) {
|
|
||||||
try {
|
|
||||||
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);
|
|
||||||
} catch (e) {
|
|
||||||
log.warn({ e }, "Failed to update Gitea comment");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDeploy(jobId: number) {
|
export async function runDeploy(jobId: number) {
|
||||||
const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
|
const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
|
||||||
if (!job || !job.preview) {
|
if (!job || !job.preview) {
|
||||||
@@ -194,7 +171,7 @@ async function firstDeploy(
|
|||||||
status: "🟡 Provisioning EC2 instance...",
|
status: "🟡 Provisioning EC2 instance...",
|
||||||
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
|
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
|
||||||
});
|
});
|
||||||
let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
|
const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
|
||||||
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
|
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
|
||||||
|
|
||||||
checkAbort(previewId);
|
checkAbort(previewId);
|
||||||
@@ -227,8 +204,7 @@ async function firstDeploy(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
|
await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
|
||||||
|
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for running state...\n`);
|
||||||
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`);
|
|
||||||
|
|
||||||
checkAbort(previewId);
|
checkAbort(previewId);
|
||||||
|
|
||||||
@@ -243,16 +219,21 @@ async function firstDeploy(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\n`);
|
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH (as root)...\n`);
|
||||||
|
|
||||||
checkAbort(previewId);
|
checkAbort(previewId);
|
||||||
|
|
||||||
|
// Connect as root. Ubuntu user-data copies authorized_keys to root and enables root SSH.
|
||||||
const sshSession = await connectSsh(instanceIp, privateKey, 300_000);
|
const sshSession = await connectSsh(instanceIp, privateKey, 300_000);
|
||||||
activeSshSessions.set(previewId, sshSession);
|
activeSshSessions.set(previewId, sshSession);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await appendLog(previewId, `[PP] SSH connected. Waiting for bootstrap to complete...\n`);
|
||||||
|
await waitForBootstrap(sshSession, 600_000);
|
||||||
|
await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`);
|
||||||
|
|
||||||
if (repoConfig.aptPackages.length > 0) {
|
if (repoConfig.aptPackages.length > 0) {
|
||||||
await runSshStep(previewId, sshSession, `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
|
await runSshStep(previewId, sshSession, `DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
|
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
|
||||||
@@ -260,14 +241,25 @@ async function firstDeploy(
|
|||||||
parsedUrl.username = encodeURIComponent(user.giteaUsername || "");
|
parsedUrl.username = encodeURIComponent(user.giteaUsername || "");
|
||||||
parsedUrl.password = encodeURIComponent(giteaPat);
|
parsedUrl.password = encodeURIComponent(giteaPat);
|
||||||
const authCloneUrl = parsedUrl.toString();
|
const authCloneUrl = parsedUrl.toString();
|
||||||
await runSshStep(previewId, sshSession, `git clone '${authCloneUrl}' /opt/app`);
|
|
||||||
|
// Log a masked version so PAT is not exposed in preview logs
|
||||||
|
const maskedUrl = `${parsedUrl.protocol}//${parsedUrl.username}:****@${parsedUrl.hostname}${parsedUrl.port ? ":" + parsedUrl.port : ""}${parsedUrl.pathname}`;
|
||||||
|
await appendLog(previewId, `$ git clone '${maskedUrl}' /opt/app\n`);
|
||||||
|
const cloneResult = await sshSession.exec(`git clone '${authCloneUrl}' /opt/app`);
|
||||||
|
if (cloneResult.stdout) await appendLog(previewId, cloneResult.stdout);
|
||||||
|
if (cloneResult.stderr) {
|
||||||
|
// Mask PAT in stderr output too
|
||||||
|
const maskedStderr = cloneResult.stderr.replace(encodeURIComponent(giteaPat), "****").replace(giteaPat, "****");
|
||||||
|
await appendLog(previewId, maskedStderr);
|
||||||
|
}
|
||||||
|
if (cloneResult.code !== 0) throw new Error(`git clone failed with exit code ${cloneResult.code}`);
|
||||||
|
|
||||||
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
|
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
|
||||||
|
|
||||||
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true);
|
await setupAndBuild(previewId, sshSession, repoConfig, commitSha, true);
|
||||||
|
|
||||||
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() });
|
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() });
|
||||||
|
|
||||||
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
|
|
||||||
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
|
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
|
||||||
buildPrCommentBody({
|
buildPrCommentBody({
|
||||||
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
|
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
|
||||||
@@ -291,16 +283,20 @@ async function redeploy(
|
|||||||
prTitle: string,
|
prTitle: string,
|
||||||
) {
|
) {
|
||||||
const previewId = preview.id;
|
const previewId = preview.id;
|
||||||
const instanceIp = preview.instanceIp!;
|
|
||||||
const privateKey = decrypt(preview.sshPrivateKey!);
|
|
||||||
|
|
||||||
const sshSession = await connectSsh(instanceIp, privateKey, 30_000);
|
// Always use fresh data from DB for connection details
|
||||||
|
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
|
||||||
|
if (!freshPreview?.instanceIp || !freshPreview?.sshPrivateKey) {
|
||||||
|
throw new Error("No active instance found for redeploy — cannot SSH in");
|
||||||
|
}
|
||||||
|
const instanceIp = freshPreview.instanceIp;
|
||||||
|
const privateKey = decrypt(freshPreview.sshPrivateKey);
|
||||||
|
|
||||||
|
const sshSession = await connectSsh(instanceIp, privateKey, 60_000);
|
||||||
activeSshSessions.set(previewId, sshSession);
|
activeSshSessions.set(previewId, sshSession);
|
||||||
|
|
||||||
await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`);
|
await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`);
|
||||||
|
|
||||||
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
|
|
||||||
|
|
||||||
const commentBody = buildPrCommentBody({
|
const commentBody = buildPrCommentBody({
|
||||||
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
|
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
|
||||||
status: `🟡 Building... (EC2 at ${instanceIp})`,
|
status: `🟡 Building... (EC2 at ${instanceIp})`,
|
||||||
@@ -313,14 +309,14 @@ async function redeploy(
|
|||||||
if (repoConfig.useDockerCompose) {
|
if (repoConfig.useDockerCompose) {
|
||||||
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
|
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
|
||||||
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);
|
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);
|
||||||
} else if (freshPreview?.pid) {
|
} else if (freshPreview.pid) {
|
||||||
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);
|
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);
|
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);
|
||||||
|
|
||||||
await updateStatus(previewId, "BUILDING");
|
await updateStatus(previewId, "BUILDING");
|
||||||
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false);
|
await setupAndBuild(previewId, sshSession, repoConfig, commitSha, false);
|
||||||
|
|
||||||
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() });
|
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() });
|
||||||
|
|
||||||
@@ -347,7 +343,6 @@ async function setupAndBuild(
|
|||||||
previewId: number,
|
previewId: number,
|
||||||
sshSession: SshSession,
|
sshSession: SshSession,
|
||||||
repoConfig: RepoConfig,
|
repoConfig: RepoConfig,
|
||||||
preview: Preview,
|
|
||||||
commitSha: string,
|
commitSha: string,
|
||||||
isFirstProvision: boolean,
|
isFirstProvision: boolean,
|
||||||
) {
|
) {
|
||||||
@@ -355,14 +350,13 @@ async function setupAndBuild(
|
|||||||
const nvmCmd = `${NVM_PREFIX} if [ -f /opt/app/.nvmrc ]; then nvm install && nvm use; else nvm use default; fi`;
|
const nvmCmd = `${NVM_PREFIX} if [ -f /opt/app/.nvmrc ]; then nvm install && nvm use; else nvm use default; fi`;
|
||||||
await runSshStep(previewId, sshSession, `bash -c '${nvmCmd}'`, false);
|
await runSshStep(previewId, sshSession, `bash -c '${nvmCmd}'`, false);
|
||||||
|
|
||||||
// Write .env file
|
// Write .env file safely using base64 to handle special chars and newlines
|
||||||
const envVars = repoConfig.envVars as Record<string, string>;
|
const envVars = repoConfig.envVars as Record<string, string>;
|
||||||
const envLines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\\n");
|
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
||||||
if (envLines) {
|
const envB64 = Buffer.from(envContent).toString("base64");
|
||||||
await runSshStep(previewId, sshSession, `printf '${envLines}\\n' > /opt/app/.env`, false);
|
await appendLog(previewId, `$ echo '<base64 .env> | base64 -d > /opt/app/.env'\n`);
|
||||||
} else {
|
const envResult = await sshSession.exec(`echo '${envB64}' | base64 -d > /opt/app/.env`);
|
||||||
await runSshStep(previewId, sshSession, `touch /opt/app/.env`, false);
|
if (envResult.code !== 0) throw new Error(".env write failed");
|
||||||
}
|
|
||||||
|
|
||||||
if (isFirstProvision) {
|
if (isFirstProvision) {
|
||||||
for (const cmd of repoConfig.setupCommands) {
|
for (const cmd of repoConfig.setupCommands) {
|
||||||
@@ -436,15 +430,24 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED
|
|||||||
|
|
||||||
if (preview.instanceId) {
|
if (preview.instanceId) {
|
||||||
const ec2 = makeEc2Client(user);
|
const ec2 = makeEc2Client(user);
|
||||||
|
// Terminate instance first, then clean up key pair and security group
|
||||||
|
try {
|
||||||
|
await terminateInstance(ec2, preview.instanceId);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn({ e, instanceId: preview.instanceId }, "Failed to terminate instance");
|
||||||
|
}
|
||||||
|
// Delete key pair immediately (doesn't depend on instance state)
|
||||||
try {
|
try {
|
||||||
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
|
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Delete security group after a delay to allow instance ENI detachment
|
||||||
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
|
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
|
||||||
} catch {}
|
} catch (e) {
|
||||||
try {
|
log.warn({ e, previewId }, "Failed to delete security group after termination");
|
||||||
await terminateInstance(ec2, preview.instanceId);
|
}
|
||||||
} catch {}
|
}, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.preview.update({
|
await prisma.preview.update({
|
||||||
|
|||||||
@@ -118,15 +118,34 @@ const BOOTSTRAP_SCRIPT = `#!/bin/bash
|
|||||||
set -e
|
set -e
|
||||||
apt-get update -y
|
apt-get update -y
|
||||||
apt-get install -y curl git unzip build-essential
|
apt-get install -y curl git unzip build-essential
|
||||||
|
|
||||||
|
# Docker
|
||||||
curl -fsSL https://get.docker.com | sh
|
curl -fsSL https://get.docker.com | sh
|
||||||
systemctl enable docker
|
systemctl enable docker
|
||||||
systemctl start docker
|
systemctl start docker
|
||||||
apt-get install -y docker-compose-plugin
|
apt-get install -y docker-compose-plugin
|
||||||
|
|
||||||
|
# Allow root SSH with key auth (sshd_config may default to prohibit-password or no)
|
||||||
|
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin without-password/' /etc/ssh/sshd_config
|
||||||
|
# Copy ubuntu's authorized_keys to root so PP can SSH as root
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
if [ -f /home/ubuntu/.ssh/authorized_keys ]; then
|
||||||
|
cp /home/ubuntu/.ssh/authorized_keys /root/.ssh/authorized_keys
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
fi
|
||||||
|
systemctl reload sshd || service ssh reload
|
||||||
|
|
||||||
|
# NVM + Node LTS (installed as root, available to root SSH sessions)
|
||||||
|
export HOME=/root
|
||||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
export NVM_DIR="/root/.nvm"
|
export NVM_DIR="/root/.nvm"
|
||||||
source "$NVM_DIR/nvm.sh"
|
source "$NVM_DIR/nvm.sh"
|
||||||
nvm install --lts
|
nvm install --lts
|
||||||
nvm alias default lts/*
|
nvm alias default lts/*
|
||||||
|
|
||||||
|
# Signal that bootstrap is complete
|
||||||
|
touch /var/lib/pp-bootstrap-done
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function launchInstance(opts: {
|
export async function launchInstance(opts: {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function tryConnect(host: string, privateKey: string, timeoutMs: number): Promis
|
|||||||
conn.connect({
|
conn.connect({
|
||||||
host,
|
host,
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "ubuntu",
|
username: "root",
|
||||||
privateKey,
|
privateKey,
|
||||||
readyTimeout: timeoutMs,
|
readyTimeout: timeoutMs,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
@@ -85,6 +85,16 @@ function execOnConn(conn: Client, command: string): Promise<{ stdout: string; st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < maxWaitMs) {
|
||||||
|
const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting");
|
||||||
|
if (res.stdout.trim() === "done") return;
|
||||||
|
await sleep(10_000);
|
||||||
|
}
|
||||||
|
throw new Error("EC2 bootstrap did not complete within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise(r => setTimeout(r, ms));
|
return new Promise(r => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,21 +28,23 @@ export function startCronWorkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkInactivity() {
|
async function checkInactivity() {
|
||||||
const settings = await getAdminSettings();
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const running = await prisma.preview.findMany({
|
const running = await prisma.preview.findMany({
|
||||||
where: { status: "RUNNING" },
|
where: { status: "RUNNING" },
|
||||||
|
include: { repoConfig: { select: { inactivityHours: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const preview of running) {
|
for (const preview of running) {
|
||||||
const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig
|
const inactivityHours = preview.repoConfig.inactivityHours;
|
||||||
const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } });
|
const deadline = new Date(preview.lastActivityAt.getTime() + inactivityHours * 3600 * 1000);
|
||||||
if (!repoConfig) continue;
|
|
||||||
|
|
||||||
const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000);
|
|
||||||
if (now >= deadline) {
|
if (now >= deadline) {
|
||||||
log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP");
|
log.info({ previewId: preview.id, inactivityHours }, "Preview inactive, enqueuing INACTIVITY_STOP");
|
||||||
|
// Only create one pending INACTIVITY_STOP per preview
|
||||||
|
const existingStop = await prisma.job.findFirst({
|
||||||
|
where: { previewId: preview.id, type: "INACTIVITY_STOP", status: { in: ["PENDING", "RUNNING"] } },
|
||||||
|
});
|
||||||
|
if (!existingStop) {
|
||||||
await prisma.job.create({
|
await prisma.job.create({
|
||||||
data: {
|
data: {
|
||||||
previewId: preview.id,
|
previewId: preview.id,
|
||||||
@@ -53,6 +55,7 @@ async function checkInactivity() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dailyCleanup() {
|
async function dailyCleanup() {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { ConfirmDialog } from "../components/ConfirmDialog";
|
|||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface EditUserForm {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
newUsername: string;
|
||||||
|
newPassword: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isFounder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function Admin() {
|
export function Admin() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [tab, setTab] = useState<"users" | "settings" | "previews">("users");
|
const [tab, setTab] = useState<"users" | "settings" | "previews">("users");
|
||||||
@@ -16,7 +25,7 @@ export function Admin() {
|
|||||||
|
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [editUser, setEditUser] = useState<any>(null);
|
const [editUser, setEditUser] = useState<EditUserForm | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
||||||
const [stopConfirm, setStopConfirm] = useState<any>(null);
|
const [stopConfirm, setStopConfirm] = useState<any>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -64,6 +73,19 @@ export function Admin() {
|
|||||||
else toast.error(res.message || "Failed to delete user");
|
else toast.error(res.message || "Failed to delete user");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editUser) return;
|
||||||
|
setSaving(true);
|
||||||
|
const data: any = {};
|
||||||
|
if (editUser.newUsername && editUser.newUsername !== editUser.username) data.username = editUser.newUsername;
|
||||||
|
if (editUser.newPassword) data.password = editUser.newPassword;
|
||||||
|
const res = await api.admin.updateUser(editUser.id, data);
|
||||||
|
setSaving(false);
|
||||||
|
if (res.ok) { toast.success("User updated"); setEditUser(null); loadUsers(); }
|
||||||
|
else toast.error(res.message || "Failed to update user");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveSettings = async (e: React.FormEvent) => {
|
const handleSaveSettings = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -102,6 +124,38 @@ export function Admin() {
|
|||||||
danger
|
danger
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Edit user modal */}
|
||||||
|
{editUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 w-full max-w-sm">
|
||||||
|
<h2 className="font-semibold text-lg mb-4">Edit User: {editUser.username}</h2>
|
||||||
|
<form onSubmit={handleSaveUser} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>Username</label>
|
||||||
|
<input type="text" value={editUser.newUsername}
|
||||||
|
onChange={e => setEditUser(u => u ? { ...u, newUsername: e.target.value } : null)}
|
||||||
|
className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>New Password (leave blank to keep current)</label>
|
||||||
|
<input type="password" value={editUser.newPassword} placeholder="New password"
|
||||||
|
onChange={e => setEditUser(u => u ? { ...u, newPassword: e.target.value } : null)}
|
||||||
|
className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="submit" disabled={saving} className={`${btnCls} flex-1`}>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setEditUser(null)}
|
||||||
|
className="flex-1 px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
|
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-slate-700">
|
<div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-slate-700">
|
||||||
@@ -123,7 +177,7 @@ export function Admin() {
|
|||||||
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
|
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
|
||||||
placeholder="Username" className={inputCls} required />
|
placeholder="Username" className={inputCls} required />
|
||||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
|
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
|
||||||
placeholder="Password" className={inputCls} required />
|
placeholder="Password (min 8 chars)" className={inputCls} required minLength={8} />
|
||||||
<button type="submit" disabled={saving} className={btnCls}>Create</button>
|
<button type="submit" disabled={saving} className={btnCls}>Create</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +207,12 @@ export function Admin() {
|
|||||||
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(u.createdAt).toLocaleDateString()}</td>
|
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditUser({ id: u.id, username: u.username, newUsername: u.username, newPassword: "", isAdmin: u.isAdmin, isFounder: u.isFounder })}
|
||||||
|
className="text-xs text-gray-600 dark:text-slate-300 hover:underline"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
{!u.isFounder && u.id !== user.id && (
|
{!u.isFounder && u.id !== user.id && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -208,7 +268,7 @@ export function Admin() {
|
|||||||
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
|
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
|
||||||
className={inputCls} min={1} />
|
className={inputCls} min={1} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Contact Email">
|
<Field label="Contact Email (shown in privacy policy)">
|
||||||
<input type="email" value={settings.contactEmail}
|
<input type="email" value={settings.contactEmail}
|
||||||
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
|
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
|
||||||
className={inputCls} placeholder="admin@example.com" />
|
className={inputCls} placeholder="admin@example.com" />
|
||||||
@@ -264,6 +324,11 @@ export function Admin() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{previews.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500 dark:text-slate-400">No previews yet.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,5 +347,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1";
|
||||||
const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500";
|
const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||||
const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50";
|
const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50";
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
|
||||||
export function Privacy() {
|
export function Privacy() {
|
||||||
|
const [contactEmail, setContactEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.admin.getSettings().then(res => {
|
||||||
|
if (res.ok && res.data?.contactEmail) setContactEmail(res.data.contactEmail);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl prose dark:prose-invert">
|
<div className="max-w-2xl prose dark:prose-invert">
|
||||||
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-4 inline-block">← Back</Link>
|
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-4 inline-block">← Back</Link>
|
||||||
@@ -10,9 +19,9 @@ export function Privacy() {
|
|||||||
|
|
||||||
<h2>What We Store</h2>
|
<h2>What We Store</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Your username and hashed password.</li>
|
<li>Your username and hashed password (bcrypt).</li>
|
||||||
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.</li>
|
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256-GCM.</li>
|
||||||
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.</li>
|
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256-GCM.</li>
|
||||||
<li>Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.</li>
|
<li>Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.</li>
|
||||||
<li>SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).</li>
|
<li>SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -26,16 +35,20 @@ export function Privacy() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Data Retention</h2>
|
<h2>Data Retention</h2>
|
||||||
<p>Preview records are retained for the number of days configured by the administrator (default: 30 days after a preview is stopped or failed). You can view this setting in the admin panel.</p>
|
<p>Preview records (logs, metadata) are retained for the number of days configured by the administrator (default: 30 days after a preview is stopped or failed). You can view this setting in the admin panel.</p>
|
||||||
|
|
||||||
<h2>EC2 Instances</h2>
|
<h2>EC2 Instances</h2>
|
||||||
<p>Preview instances are launched in your own AWS account. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances.</p>
|
<p>Preview instances are launched in your own AWS account using your credentials. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances beyond what is captured in preview logs.</p>
|
||||||
|
|
||||||
<h2>Analytics & Tracking</h2>
|
<h2>Analytics & Tracking</h2>
|
||||||
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p>
|
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
|
{contactEmail ? (
|
||||||
|
<p>For questions or concerns, contact the instance administrator at <a href={`mailto:${contactEmail}`} className="text-blue-600 dark:text-blue-400 underline">{contactEmail}</a>.</p>
|
||||||
|
) : (
|
||||||
<p>For questions or concerns, contact the instance administrator.</p>
|
<p>For questions or concerns, contact the instance administrator.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ const IAM_POLICY = `{
|
|||||||
"ec2:DeleteSecurityGroup",
|
"ec2:DeleteSecurityGroup",
|
||||||
"ec2:AuthorizeSecurityGroupIngress",
|
"ec2:AuthorizeSecurityGroupIngress",
|
||||||
"ec2:DescribeSecurityGroups",
|
"ec2:DescribeSecurityGroups",
|
||||||
"ec2:ImportKeyPair",
|
"ec2:CreateKeyPair",
|
||||||
"ec2:DeleteKeyPair",
|
"ec2:DeleteKeyPair",
|
||||||
"ec2:CreateTags",
|
"ec2:CreateTags",
|
||||||
"sts:GetCallerIdentity"
|
"sts:GetCallerIdentity"
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function SetupWizard() {
|
|||||||
"Action": ["ec2:RunInstances","ec2:TerminateInstances",
|
"Action": ["ec2:RunInstances","ec2:TerminateInstances",
|
||||||
"ec2:DescribeInstances","ec2:CreateSecurityGroup",
|
"ec2:DescribeInstances","ec2:CreateSecurityGroup",
|
||||||
"ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress",
|
"ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress",
|
||||||
"ec2:DescribeSecurityGroups","ec2:ImportKeyPair",
|
"ec2:DescribeSecurityGroups","ec2:CreateKeyPair",
|
||||||
"ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"],
|
"ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"],
|
||||||
"Resource": "*"
|
"Resource": "*"
|
||||||
}]
|
}]
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
packages:
|
packages:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
allowBuilds:
|
||||||
|
'@prisma/client': set this to true or false
|
||||||
|
'@prisma/engines': set this to true or false
|
||||||
|
bufferutil: set this to true or false
|
||||||
|
cpu-features: set this to true or false
|
||||||
|
esbuild: set this to true or false
|
||||||
|
prisma: set this to true or false
|
||||||
|
ssh2: set this to true or false
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@prisma/client'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- bufferutil
|
||||||
|
- cpu-features
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
|
- ssh2
|
||||||
|
|||||||
Reference in New Issue
Block a user