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:
2026-07-25 08:34:50 +00:00
parent f974840868
commit 25f3612561
9 changed files with 211 additions and 81 deletions
+69 -3
View File
@@ -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";
+21 -8
View File
@@ -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 &amp; Tracking</h2>
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p>
<h2>Contact</h2>
<p>For questions or concerns, contact the instance administrator.</p>
{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>
);
}
+1 -1
View File
@@ -280,7 +280,7 @@ const IAM_POLICY = `{
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups",
"ec2:ImportKeyPair",
"ec2:CreateKeyPair",
"ec2:DeleteKeyPair",
"ec2:CreateTags",
"sts:GetCallerIdentity"
+1 -1
View File
@@ -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": "*"
}]