fix(v2): critical bug fixes — root SSH, bootstrap wait, SG deletion order, PAT masking
- EC2 bootstrap now enables root SSH (copies authorized_keys to root, sets PermitRootLogin without-password, reloads sshd) so all commands run as root and NVM at /root/.nvm is accessible - Added /var/lib/pp-bootstrap-done sentinel; deploy waits for it before running any user commands — prevents race between SSH availability and user-data completion (docker/nvm install can take 3-5+ min) - Fixed stopPreview: terminate instance first, delete key pair next, then delete security group with 30s delay — SG deletion was previously attempted before termination causing it to fail - Fixed redeploy to always fetch fresh instanceIp/sshPrivateKey from DB rather than using potentially-stale preview parameter - Fixed .env writing to use base64 encoding via echo|base64-d to safely handle values with special characters, single quotes, and newlines - PAT and git clone URL now masked in preview logs (shows **** for password) - Fixed inactivity cron: removed dead inactivityMs variable, use join on repoConfig to avoid N+1, deduplicate pending INACTIVITY_STOP jobs - Fixed IAM policy UI: ec2:CreateKeyPair (backend uses CreateKeyPair, not ImportKeyPair which is a different AWS operation) - Admin panel: added Edit button with username/password form for users - Privacy page: fetch and display admin contactEmail from settings - pnpm-workspace.yaml: fix allowBuilds→onlyBuiltDependencies for pnpm 9 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
deleteKeyPairAws,
|
||||
deleteSecurityGroupAws,
|
||||
} from "./ec2";
|
||||
import { connectSsh, type SshSession } from "./ssh";
|
||||
import { connectSsh, waitForBootstrap, type SshSession } from "./ssh";
|
||||
import {
|
||||
buildPrCommentBody,
|
||||
postComment,
|
||||
@@ -70,29 +70,6 @@ async function updateStatus(previewId: number, status: Preview["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) {
|
||||
const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
|
||||
if (!job || !job.preview) {
|
||||
@@ -194,7 +171,7 @@ async function firstDeploy(
|
||||
status: "🟡 Provisioning EC2 instance...",
|
||||
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 } });
|
||||
|
||||
checkAbort(previewId);
|
||||
@@ -227,8 +204,7 @@ async function firstDeploy(
|
||||
});
|
||||
|
||||
await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
|
||||
|
||||
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`);
|
||||
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for running state...\n`);
|
||||
|
||||
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);
|
||||
|
||||
// Connect as root. Ubuntu user-data copies authorized_keys to root and enables root SSH.
|
||||
const sshSession = await connectSsh(instanceIp, privateKey, 300_000);
|
||||
activeSshSessions.set(previewId, sshSession);
|
||||
|
||||
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) {
|
||||
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) : "";
|
||||
@@ -260,14 +241,25 @@ async function firstDeploy(
|
||||
parsedUrl.username = encodeURIComponent(user.giteaUsername || "");
|
||||
parsedUrl.password = encodeURIComponent(giteaPat);
|
||||
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 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() });
|
||||
|
||||
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
|
||||
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
|
||||
buildPrCommentBody({
|
||||
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
|
||||
@@ -291,16 +283,20 @@ async function redeploy(
|
||||
prTitle: string,
|
||||
) {
|
||||
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);
|
||||
|
||||
await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`);
|
||||
|
||||
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
|
||||
|
||||
const commentBody = buildPrCommentBody({
|
||||
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
|
||||
status: `🟡 Building... (EC2 at ${instanceIp})`,
|
||||
@@ -313,14 +309,14 @@ async function redeploy(
|
||||
if (repoConfig.useDockerCompose) {
|
||||
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
|
||||
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, `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 setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false);
|
||||
await setupAndBuild(previewId, sshSession, repoConfig, commitSha, false);
|
||||
|
||||
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() });
|
||||
|
||||
@@ -347,7 +343,6 @@ async function setupAndBuild(
|
||||
previewId: number,
|
||||
sshSession: SshSession,
|
||||
repoConfig: RepoConfig,
|
||||
preview: Preview,
|
||||
commitSha: string,
|
||||
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`;
|
||||
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 envLines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\\n");
|
||||
if (envLines) {
|
||||
await runSshStep(previewId, sshSession, `printf '${envLines}\\n' > /opt/app/.env`, false);
|
||||
} else {
|
||||
await runSshStep(previewId, sshSession, `touch /opt/app/.env`, false);
|
||||
}
|
||||
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
||||
const envB64 = Buffer.from(envContent).toString("base64");
|
||||
await appendLog(previewId, `$ echo '<base64 .env> | base64 -d > /opt/app/.env'\n`);
|
||||
const envResult = await sshSession.exec(`echo '${envB64}' | base64 -d > /opt/app/.env`);
|
||||
if (envResult.code !== 0) throw new Error(".env write failed");
|
||||
|
||||
if (isFirstProvision) {
|
||||
for (const cmd of repoConfig.setupCommands) {
|
||||
@@ -436,15 +430,24 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED
|
||||
|
||||
if (preview.instanceId) {
|
||||
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 {
|
||||
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
|
||||
} catch {}
|
||||
// Delete security group after a delay to allow instance ENI detachment
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
|
||||
} catch {}
|
||||
try {
|
||||
await terminateInstance(ec2, preview.instanceId);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
log.warn({ e, previewId }, "Failed to delete security group after termination");
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
await prisma.preview.update({
|
||||
|
||||
@@ -118,15 +118,34 @@ const BOOTSTRAP_SCRIPT = `#!/bin/bash
|
||||
set -e
|
||||
apt-get update -y
|
||||
apt-get install -y curl git unzip build-essential
|
||||
|
||||
# Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
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
|
||||
export NVM_DIR="/root/.nvm"
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
nvm install --lts
|
||||
nvm alias default lts/*
|
||||
|
||||
# Signal that bootstrap is complete
|
||||
touch /var/lib/pp-bootstrap-done
|
||||
`;
|
||||
|
||||
export async function launchInstance(opts: {
|
||||
|
||||
@@ -60,7 +60,7 @@ function tryConnect(host: string, privateKey: string, timeoutMs: number): Promis
|
||||
conn.connect({
|
||||
host,
|
||||
port: 22,
|
||||
username: "ubuntu",
|
||||
username: "root",
|
||||
privateKey,
|
||||
readyTimeout: timeoutMs,
|
||||
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) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
@@ -28,21 +28,23 @@ export function startCronWorkers() {
|
||||
}
|
||||
|
||||
async function checkInactivity() {
|
||||
const settings = await getAdminSettings();
|
||||
const now = new Date();
|
||||
|
||||
const running = await prisma.preview.findMany({
|
||||
where: { status: "RUNNING" },
|
||||
include: { repoConfig: { select: { inactivityHours: true } } },
|
||||
});
|
||||
|
||||
for (const preview of running) {
|
||||
const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig
|
||||
const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } });
|
||||
if (!repoConfig) continue;
|
||||
|
||||
const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000);
|
||||
const inactivityHours = preview.repoConfig.inactivityHours;
|
||||
const deadline = new Date(preview.lastActivityAt.getTime() + inactivityHours * 3600 * 1000);
|
||||
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({
|
||||
data: {
|
||||
previewId: preview.id,
|
||||
@@ -53,6 +55,7 @@ async function checkInactivity() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dailyCleanup() {
|
||||
|
||||
@@ -6,6 +6,15 @@ import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface EditUserForm {
|
||||
id: number;
|
||||
username: string;
|
||||
newUsername: string;
|
||||
newPassword: string;
|
||||
isAdmin: boolean;
|
||||
isFounder: boolean;
|
||||
}
|
||||
|
||||
export function Admin() {
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState<"users" | "settings" | "previews">("users");
|
||||
@@ -16,7 +25,7 @@ export function Admin() {
|
||||
|
||||
const [newUsername, setNewUsername] = 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 [stopConfirm, setStopConfirm] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -64,6 +73,19 @@ export function Admin() {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
@@ -102,6 +124,38 @@ export function Admin() {
|
||||
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>
|
||||
|
||||
<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)}
|
||||
placeholder="Username" className={inputCls} required />
|
||||
<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>
|
||||
</form>
|
||||
</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-right">
|
||||
<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 && (
|
||||
<>
|
||||
<button
|
||||
@@ -208,7 +268,7 @@ export function Admin() {
|
||||
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
|
||||
className={inputCls} min={1} />
|
||||
</Field>
|
||||
<Field label="Contact Email">
|
||||
<Field label="Contact Email (shown in privacy policy)">
|
||||
<input type="email" value={settings.contactEmail}
|
||||
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
|
||||
className={inputCls} placeholder="admin@example.com" />
|
||||
@@ -264,6 +324,11 @@ export function Admin() {
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</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 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 { api } from "../services/api";
|
||||
|
||||
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 (
|
||||
<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>
|
||||
@@ -10,9 +19,9 @@ export function Privacy() {
|
||||
|
||||
<h2>What We Store</h2>
|
||||
<ul>
|
||||
<li>Your username and hashed password.</li>
|
||||
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.</li>
|
||||
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.</li>
|
||||
<li>Your username and hashed password (bcrypt).</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-GCM.</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>
|
||||
</ul>
|
||||
@@ -26,16 +35,20 @@ export function Privacy() {
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ const IAM_POLICY = `{
|
||||
"ec2:DeleteSecurityGroup",
|
||||
"ec2:AuthorizeSecurityGroupIngress",
|
||||
"ec2:DescribeSecurityGroups",
|
||||
"ec2:ImportKeyPair",
|
||||
"ec2:CreateKeyPair",
|
||||
"ec2:DeleteKeyPair",
|
||||
"ec2:CreateTags",
|
||||
"sts:GetCallerIdentity"
|
||||
|
||||
@@ -148,7 +148,7 @@ export function SetupWizard() {
|
||||
"Action": ["ec2:RunInstances","ec2:TerminateInstances",
|
||||
"ec2:DescribeInstances","ec2:CreateSecurityGroup",
|
||||
"ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress",
|
||||
"ec2:DescribeSecurityGroups","ec2:ImportKeyPair",
|
||||
"ec2:DescribeSecurityGroups","ec2:CreateKeyPair",
|
||||
"ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"],
|
||||
"Resource": "*"
|
||||
}]
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
packages:
|
||||
- backend
|
||||
- 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