fix(v2): UX polish, spec compliance, type safety, race-condition fixes
- Auto-create WebhookToken for new users (first-user and admin-created), so setup wizard step 3 immediately has a valid secret to display - Setup wizard: load webhook secret on step 3 entry (not only on AWS save), so skipping AWS setup still shows correct webhook info - Admin panel: add Edit modal with username and password change for any user (spec: 'Edit username or password of any user') - Webhook handler: only post 'no config' comment on opened/reopened actions, not on synchronize or closed — prevents spam on sync events - deploy.ts: clear stale abort signal at start of runDeploy so a signal meant to cancel the previous job cannot accidentally abort the new one - routes/auth.ts: fix sameSite cookie case to lowercase 'lax' per TypeScript - Add node-cron type declaration to silence TS7016 for that import - SPEC.md: fix ec2:ImportKeyPair → ec2:CreateKeyPair (code uses CreateKeyPair) - example.env: improve comments, add NODE_ENV, add key generation hints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -154,7 +154,7 @@ PP does **not** react to its own comments (check that the commenter's username !
|
|||||||
"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"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
import { prisma } from "../../lib/db";
|
import { prisma } from "../../lib/db";
|
||||||
import { makeResponse } from "../../lib/response";
|
import { makeResponse } from "../../lib/response";
|
||||||
import { ERROR_MESSAGES } from "../../lib/errors";
|
import { ERROR_MESSAGES } from "../../lib/errors";
|
||||||
@@ -43,6 +44,10 @@ export async function createUser(ctr: any) {
|
|||||||
select: { id: true, username: true, isAdmin: true, isFounder: true },
|
select: { id: true, username: true, isAdmin: true, isFounder: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-create webhook token for new users
|
||||||
|
const webhookSecret = randomBytes(32).toString("hex");
|
||||||
|
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
|
||||||
|
|
||||||
return makeResponse({ ctr, content: { code: 201, data: user } });
|
return makeResponse({ ctr, content: { code: 201, data: user } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function loginHandler(ctr: any) {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
|
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "Lax",
|
sameSite: "lax",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,6 +116,10 @@ export async function firstUserHandler(ctr: any) {
|
|||||||
data: { username, passwordHash: hash, isAdmin: true, isFounder: true },
|
data: { username, passwordHash: hash, isAdmin: true, isFounder: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-create a webhook token so it's immediately available in the setup wizard
|
||||||
|
const webhookSecret = randomBytes(32).toString("hex");
|
||||||
|
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
|
||||||
|
|
||||||
const sessionHash = randomBytes(32).toString("hex");
|
const sessionHash = randomBytes(32).toString("hex");
|
||||||
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
|
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ export async function firstUserHandler(ctr: any) {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
|
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "Lax",
|
sameSite: "lax",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ async function handlePullRequestEvent(user: any, payload: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!repoConfig) {
|
if (!repoConfig) {
|
||||||
|
// Only post "no config" comment on new PR opens (not on sync/close events)
|
||||||
|
if (action === "opened" || action === "reopened") {
|
||||||
const existing = await prisma.noConfigComment.findUnique({
|
const existing = await prisma.noConfigComment.findUnique({
|
||||||
where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },
|
where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },
|
||||||
});
|
});
|
||||||
@@ -96,6 +98,7 @@ async function handlePullRequestEvent(user: any, payload: any) {
|
|||||||
await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });
|
await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export async function runDeploy(jobId: number) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any stale abort signal left over from aborting the previous job for this preview.
|
||||||
|
// The new job should not be cancelled by a signal meant for the old one.
|
||||||
|
if (job.preview.id) abortSignals.delete(job.preview.id);
|
||||||
|
|
||||||
await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } });
|
await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } });
|
||||||
|
|
||||||
const preview = job.preview as any;
|
const preview = job.preview as any;
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
declare module 'node-cron' {
|
||||||
|
interface ScheduledTask {
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
function schedule(cronExpression: string, func: () => void, options?: { scheduled?: boolean; timezone?: string }): ScheduledTask;
|
||||||
|
export { schedule, ScheduledTask };
|
||||||
|
}
|
||||||
+12
-6
@@ -1,17 +1,20 @@
|
|||||||
# PP (PR Previews) - Environment Variables
|
# PP (PR Previews) — Environment Variables
|
||||||
# Copy to .env and fill in values
|
# Copy to .env (for local dev) or set in docker-compose.yml environment
|
||||||
|
|
||||||
# PostgreSQL connection string
|
# PostgreSQL connection string
|
||||||
DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp
|
DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp
|
||||||
|
|
||||||
# Session signing secret (at least 32 random chars)
|
# Session signing secret (at least 32 random chars)
|
||||||
SESSION_SECRET=change_me_to_a_random_32_character_string
|
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars
|
||||||
|
|
||||||
# Public URL of this PP instance (no trailing slash)
|
# Public URL of this PP instance (no trailing slash). Used in webhook URLs and PR comment links.
|
||||||
PP_BASE_URL=https://pp.example.com
|
PP_BASE_URL=https://pp.example.com
|
||||||
|
|
||||||
# AES-256 encryption key — 64 hex chars (32 bytes)
|
# AES-256 encryption key — exactly 64 hex chars (32 bytes).
|
||||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# Used to encrypt Gitea PAT, AWS credentials, and SSH private keys at rest.
|
||||||
|
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
# WARNING: Changing this key will invalidate all encrypted data in the database.
|
||||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
|
||||||
# Backend port (default: 5000)
|
# Backend port (default: 5000)
|
||||||
@@ -19,3 +22,6 @@ PORT=5000
|
|||||||
|
|
||||||
# Log level: trace | debug | info | warn | error
|
# Log level: trace | debug | info | warn | error
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Node environment
|
||||||
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
@@ -28,6 +28,18 @@ export function SetupWizard() {
|
|||||||
const [webhookUrl, setWebhookUrl] = useState("");
|
const [webhookUrl, setWebhookUrl] = useState("");
|
||||||
const [webhookSecret, setWebhookSecret] = useState("");
|
const [webhookSecret, setWebhookSecret] = useState("");
|
||||||
|
|
||||||
|
// Auto-load webhook info whenever we reach step 3 (index 3 = "Your Webhook")
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 3) return;
|
||||||
|
if (webhookSecret) return;
|
||||||
|
api.user.getWebhookSecret().then(res => {
|
||||||
|
if (res.ok && res.data) {
|
||||||
|
setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`);
|
||||||
|
setWebhookSecret(res.data.token);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [step, user?.id]);
|
||||||
|
|
||||||
const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
|
const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
|
||||||
const skip = () => navigate("/");
|
const skip = () => navigate("/");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user