feat: initial scaffold - backend, frontend, Prisma schema, Docker

- Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings
- Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers
- Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page
- Docker Compose and Dockerfile for self-hosted deployment
- Uses bcryptjs for Node 24 compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:18:08 +02:00
parent ca2efdadea
commit 40d484bede
108 changed files with 13393 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect } from "react";
import { Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { AuthContext, useAuthProvider } from "./hooks/useAuth";
import { useTheme } from "./hooks/useTheme";
import { Layout } from "./components/Layout";
import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard";
import { PreviewDetail } from "./pages/PreviewDetail";
import { Settings } from "./pages/Settings";
import { Repos } from "./pages/Repos";
import { Admin } from "./pages/Admin";
import { SetupWizard } from "./pages/SetupWizard";
import { Privacy } from "./pages/Privacy";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = React.useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
if (!loading && !user) navigate("/login");
}, [user, loading, navigate]);
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-2xl animate-spin"></div>
</div>
);
if (!user) return null;
return <>{children}</>;
}
function SetupCheck({ children }: { children: React.ReactNode }) {
const { user } = React.useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
if (user && !user.setupComplete) {
const path = window.location.pathname;
if (path !== "/setup" && path !== "/settings" && path !== "/privacy") {
navigate("/setup");
}
}
}, [user, navigate]);
return <>{children}</>;
}
export default function App() {
const auth = useAuthProvider();
useTheme();
return (
<AuthContext.Provider value={auth}>
<ToastContainer
position="top-right"
autoClose={4000}
hideProgressBar={false}
newestOnTop
closeOnClick
theme="colored"
/>
<Routes>
<Route path="/login" element={
auth.user ? <Navigate to="/" replace /> : <Login />
} />
<Route path="/privacy" element={<Layout><Privacy /></Layout>} />
<Route path="/*" element={
<ProtectedRoute>
<SetupCheck>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/previews/:id" element={<PreviewDetail />} />
<Route path="/repos" element={<Repos />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="/setup" element={<SetupWizard />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</SetupCheck>
</ProtectedRoute>
} />
</Routes>
</AuthContext.Provider>
);
}
+56
View File
@@ -0,0 +1,56 @@
import React from "react";
import { motion, AnimatePresence } from "motion/react";
interface Props {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
danger?: boolean;
}
export function ConfirmDialog({ open, title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger }: Props) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white dark:bg-slate-800 rounded-xl shadow-xl p-6 max-w-md w-full"
onClick={e => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-gray-600 dark:text-slate-300 text-sm mb-6">{message}</p>
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm text-white font-medium transition-colors ${
danger ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700"
}`}
>
{confirmLabel}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+82
View File
@@ -0,0 +1,82 @@
import React from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { useTheme } from "../hooks/useTheme";
import { api } from "../services/api";
import { toast } from "react-toastify";
export function Layout({ children }: { children: React.ReactNode }) {
const { user, refresh } = useAuth();
const { dark, toggle } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const handleLogout = async () => {
await api.auth.logout();
await refresh();
navigate("/login");
};
const navLink = (to: string, label: string) => (
<Link
to={to}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
location.pathname === to || location.pathname.startsWith(to + "/")
? "bg-blue-600 text-white"
: "text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-800"
}`}
>
{label}
</Link>
);
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80">
🚀 PR Previews
</Link>
</div>
{user && (
<nav className="flex items-center gap-1 overflow-x-auto">
{navLink("/", "Previews")}
{navLink("/repos", "Repos")}
{navLink("/settings", "Settings")}
{user.isAdmin && navLink("/admin", "Admin")}
</nav>
)}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={toggle}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800 text-lg"
title="Toggle theme"
>
{dark ? "☀️" : "🌙"}
</button>
{user && (
<button
onClick={handleLogout}
className="text-sm px-3 py-1.5 rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors"
>
Logout
</button>
)}
</div>
</div>
</header>
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
{children}
</main>
<footer className="border-t border-gray-200 dark:border-slate-700 py-4 text-center text-xs text-gray-500 dark:text-slate-400">
PR Previews self-hosted preview environments for every pull request.{" "}
<Link to="/privacy" className="underline hover:text-gray-700 dark:hover:text-slate-200">Privacy Policy</Link>
</footer>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { useEffect, useRef, useState } from "react";
function stripAnsi(str: string): string {
return str.replace(/\x1B\[[\d;]*[mGKHFJsu]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
}
function renderLogLine(line: string, idx: number) {
if (line.startsWith("--- ") && (line.includes("Redeploy") || line.includes("truncated"))) {
return (
<span key={idx} className="text-slate-400 dark:text-slate-500 italic block">
{line}
</span>
);
}
return <span key={idx} className="block">{stripAnsi(line)}</span>;
}
interface Props {
logs: string;
autoScroll?: boolean;
maxHeight?: string;
}
export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(autoScroll);
useEffect(() => {
if (pinned && endRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logs, pinned]);
const lines = logs.split("\n");
return (
<div className="relative">
<div
className="log-viewer bg-gray-950 dark:bg-black text-green-400 rounded-lg p-4 overflow-auto border border-gray-800"
style={{ maxHeight }}
onScroll={(e) => {
const el = e.currentTarget;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setPinned(atBottom);
}}
>
<pre className="ansi-stripped text-xs">
{lines.map((line, i) => renderLogLine(line, i))}
</pre>
<div ref={endRef} />
</div>
{!pinned && (
<button
onClick={() => { setPinned(true); endRef.current?.scrollIntoView({ behavior: "smooth" }); }}
className="absolute bottom-4 right-4 text-xs bg-blue-600 text-white px-2 py-1 rounded shadow hover:bg-blue-700"
>
Jump to bottom
</button>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED";
const CONFIG: Record<Status, { label: string; icon: string; cls: string }> = {
PROVISIONING: { label: "Provisioning", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
BUILDING: { label: "Building", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
RUNNING: { label: "Running", icon: "🟢", cls: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
FAILED: { label: "Failed", icon: "🔴", cls: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
STOPPED: { label: "Stopped", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
IGNORED: { label: "Ignored", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
};
export function StatusBadge({ status }: { status: Status }) {
const cfg = CONFIG[status] || CONFIG.STOPPED;
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.cls}`}>
<span>{cfg.icon}</span> {cfg.label}
</span>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect, createContext, useContext } from "react";
import { api } from "../services/api";
export interface AuthUser {
id: number;
username: string;
isAdmin: boolean;
isFounder: boolean;
setupComplete: boolean;
giteaUsername: string | null;
giteaInstanceUrl: string | null;
giteaPatSet: boolean;
awsAccessKeyId: string | null;
awsRegion: string | null;
awsConfigured: boolean;
}
interface AuthContextType {
user: AuthUser | null;
loading: boolean;
refresh: () => Promise<void>;
}
import { createContext as _createContext } from "react";
export const AuthContext = _createContext<AuthContextType>({
user: null,
loading: true,
refresh: async () => {},
});
export function useAuth() {
return useContext(AuthContext);
}
export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = async () => {
const res = await api.auth.me();
if (res.ok && res.data) {
setUser(res.data as AuthUser);
} else {
setUser(null);
}
setLoading(false);
};
useEffect(() => {
refresh();
}, []);
return { user, loading, refresh };
}
+16
View File
@@ -0,0 +1,16 @@
import { useState, useEffect } from "react";
export function useTheme() {
const [dark, setDark] = useState(() => {
const stored = localStorage.getItem("pp-theme");
if (stored) return stored === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("pp-theme", dark ? "dark" : "light");
}, [dark]);
return { dark, toggle: () => setDark(d => !d) };
}
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--bg: #ffffff;
--text: #111827;
}
.dark {
--bg: #0f172a;
--text: #f1f5f9;
}
}
body {
@apply bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100 transition-colors;
font-family: system-ui, sans-serif;
}
.log-viewer {
font-family: "JetBrains Mono", "Fira Code", Consolas, monospace;
font-size: 0.8rem;
line-height: 1.5;
}
.redeploy-separator {
@apply text-slate-400 dark:text-slate-500 italic;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-slate-800;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-slate-600 rounded;
}
.ansi-stripped { white-space: pre-wrap; word-break: break-all; }
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+286
View File
@@ -0,0 +1,286 @@
import React, { useEffect, useState } from "react";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { StatusBadge } from "../components/StatusBadge";
import { Link } from "react-router-dom";
export function Admin() {
const { user } = useAuth();
const [tab, setTab] = useState<"users" | "settings" | "previews">("users");
const [users, setUsers] = useState<any[]>([]);
const [settings, setSettings] = useState<any>(null);
const [previews, setPreviews] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [editUser, setEditUser] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [stopConfirm, setStopConfirm] = useState<any>(null);
const [saving, setSaving] = useState(false);
const loadUsers = async () => {
const res = await api.admin.listUsers();
if (res.ok) setUsers(res.data || []);
};
const loadSettings = async () => {
const res = await api.admin.getSettings();
if (res.ok) setSettings(res.data);
};
const loadPreviews = async () => {
const res = await api.admin.listPreviews();
if (res.ok) setPreviews(res.data || []);
};
useEffect(() => {
if (!user?.isAdmin) return;
setLoading(true);
Promise.all([loadUsers(), loadSettings(), loadPreviews()]).finally(() => setLoading(false));
}, [user]);
if (!user?.isAdmin) return (
<div className="text-center py-20">
<div className="text-5xl mb-4">🚫</div>
<h2 className="text-xl font-semibold">Access Denied</h2>
</div>
);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
const res = await api.admin.createUser(newUsername, newPassword);
setSaving(false);
if (res.ok) { toast.success("User created"); setNewUsername(""); setNewPassword(""); loadUsers(); }
else toast.error(res.message || "Failed to create user");
};
const handleDeleteUser = async () => {
if (!deleteConfirm) return;
const res = await api.admin.deleteUser(deleteConfirm.id);
setDeleteConfirm(null);
if (res.ok) { toast.success("User deleted"); loadUsers(); }
else toast.error(res.message || "Failed to delete user");
};
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
const res = await api.admin.updateSettings(settings);
setSaving(false);
if (res.ok) toast.success("Settings saved");
else toast.error(res.message || "Failed to save settings");
};
const handleStopPreview = async () => {
if (!stopConfirm) return;
const res = await api.admin.stopPreview(stopConfirm.id);
setStopConfirm(null);
if (res.ok) { toast.success("Stop job enqueued"); loadPreviews(); }
else toast.error(res.message || "Failed to stop preview");
};
return (
<div className="max-w-5xl">
<ConfirmDialog
open={!!deleteConfirm}
title="Delete User"
message={`Delete user "${deleteConfirm?.username}"? All their data will be removed.`}
confirmLabel="Delete User"
onConfirm={handleDeleteUser}
onCancel={() => setDeleteConfirm(null)}
danger
/>
<ConfirmDialog
open={!!stopConfirm}
title="Stop Preview"
message={`Stop preview #${stopConfirm?.id} for ${stopConfirm?.user?.username}?`}
confirmLabel="Stop"
onConfirm={handleStopPreview}
onCancel={() => setStopConfirm(null)}
danger
/>
<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">
{(["users", "settings", "previews"] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
tab === t ? "border-blue-600 text-blue-600 dark:text-blue-400" : "border-transparent text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-slate-200"
}`}>
{t}
</button>
))}
</div>
{tab === "users" && (
<div className="space-y-6">
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-5">
<h2 className="font-semibold mb-4">Create User</h2>
<form onSubmit={handleCreateUser} className="flex gap-3">
<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 />
<button type="submit" disabled={saving} className={btnCls}>Create</button>
</form>
</div>
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 text-xs uppercase text-gray-500 dark:text-slate-400">
<th className="px-4 py-3 text-left">Username</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-gray-100 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3 font-medium">
{u.username}
{u.isFounder && <span className="ml-2 text-xs bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300 px-1.5 py-0.5 rounded-full">Founder</span>}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${u.isAdmin ? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" : "bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300"}`}>
{u.isAdmin ? "Admin" : "User"}
</span>
</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">
<div className="flex gap-2 justify-end">
{!u.isFounder && u.id !== user.id && (
<>
<button
onClick={async () => {
const res = await api.admin.updateUser(u.id, { isAdmin: !u.isAdmin });
if (res.ok) { toast.success(`User ${u.isAdmin ? "demoted" : "promoted"}`); loadUsers(); }
else toast.error(res.message || "Failed");
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{u.isAdmin ? "Demote" : "Promote"}
</button>
<button onClick={() => setDeleteConfirm(u)}
className="text-xs text-red-600 dark:text-red-400 hover:underline">Delete</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === "settings" && settings && (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-5">
<h2 className="font-semibold mb-4">Global Settings</h2>
<form onSubmit={handleSaveSettings} className="space-y-4">
<Field label="Default EC2 Instance Type">
<input type="text" value={settings.defaultInstanceType}
onChange={e => setSettings((s: any) => ({ ...s, defaultInstanceType: e.target.value }))}
className={inputCls} />
</Field>
<Field label="Max Concurrent Instances Per User">
<input type="number" value={settings.maxConcurrentInstancesPerUser}
onChange={e => setSettings((s: any) => ({ ...s, maxConcurrentInstancesPerUser: Number(e.target.value) }))}
className={inputCls} min={1} max={50} />
</Field>
<Field label="Webhook Rate Limit (per minute per user)">
<input type="number" value={settings.webhookRateLimitPerMinute}
onChange={e => setSettings((s: any) => ({ ...s, webhookRateLimitPerMinute: Number(e.target.value) }))}
className={inputCls} min={1} />
</Field>
<Field label="Log Size Limit (bytes)">
<input type="number" value={settings.logSizeLimitBytes}
onChange={e => setSettings((s: any) => ({ ...s, logSizeLimitBytes: Number(e.target.value) }))}
className={inputCls} min={1024} />
</Field>
<Field label="Preview Retention (days)">
<input type="number" value={settings.previewRetentionDays}
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
className={inputCls} min={1} />
</Field>
<Field label="Contact Email">
<input type="email" value={settings.contactEmail}
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
className={inputCls} placeholder="admin@example.com" />
</Field>
<button type="submit" disabled={saving} className={btnCls}>
{saving ? "Saving..." : "Save Settings"}
</button>
</form>
</div>
)}
{tab === "previews" && (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
<h2 className="font-semibold">All Previews</h2>
<button onClick={loadPreviews} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> Refresh</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 text-xs uppercase text-gray-500 dark:text-slate-400">
<th className="px-4 py-3 text-left">Repo / PR</th>
<th className="px-4 py-3 text-left">User</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">IP</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{previews.map(p => (
<tr key={p.id} className="border-b border-gray-100 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p>
<Link to={`/previews/${p.id}`} className="font-medium hover:text-blue-600 dark:hover:text-blue-400">PR #{p.prNumber}</Link>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{p.user?.username}</td>
<td className="px-4 py-3"><StatusBadge status={p.status} /></td>
<td className="px-4 py-3">
{p.instanceIp ? (
<a href={`http://${p.instanceIp}:${p.port}`} target="_blank" rel="noreferrer"
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline">
{p.instanceIp}:{p.port}
</a>
) : "—"}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(p.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right">
{p.status !== "STOPPED" && p.status !== "IGNORED" && (
<button onClick={() => setStopConfirm(p)}
className="text-xs text-red-600 dark:text-red-400 hover:underline">Stop</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">{label}</label>
{children}
</div>
);
}
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";
+129
View File
@@ -0,0 +1,129 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../services/api";
import { StatusBadge } from "../components/StatusBadge";
import { motion } from "motion/react";
import { useAuth } from "../hooks/useAuth";
interface Preview {
id: number;
prNumber: number;
prTitle: string;
commitSha: string;
status: string;
instanceIp: string | null;
port: number;
createdAt: string;
updatedAt: string;
lastActivityAt: string;
repoOwner: string;
repoName: string;
}
export function Dashboard() {
const [previews, setPreviews] = useState<Preview[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
const load = async () => {
const res = await api.previews.list();
if (res.ok) setPreviews(res.data || []);
setLoading(false);
};
useEffect(() => { load(); }, []);
useEffect(() => {
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, []);
if (!user?.setupComplete && !loading) {
return (
<div className="text-center py-20">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold mb-2">Setup Required</h2>
<p className="text-gray-600 dark:text-slate-400 mb-6">Configure your Gitea and AWS credentials to get started.</p>
<Link
to="/settings"
className="inline-flex px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Go to Settings
</Link>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Previews</h1>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
Refresh
</button>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-40 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />
))}
</div>
) : previews.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">🔍</div>
<h2 className="text-xl font-semibold mb-2">No previews yet</h2>
<p className="text-gray-500 dark:text-slate-400 mb-4">
Enable a repo and open a pull request to create your first preview.
</p>
<Link to="/repos" className="text-blue-600 dark:text-blue-400 hover:underline">
Configure repos
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{previews.map((p, i) => (
<motion.div
key={p.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
>
<Link
to={`/previews/${p.id}`}
className="block bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
>
<div className="flex items-start justify-between mb-2">
<div>
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p>
<p className="font-semibold text-sm mt-0.5 line-clamp-1">PR #{p.prNumber}: {p.prTitle}</p>
</div>
<StatusBadge status={p.status as any} />
</div>
<p className="text-xs text-gray-500 dark:text-slate-400 mb-2">
Commit: <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">{p.commitSha.slice(0, 8)}</code>
</p>
{p.status === "RUNNING" && p.instanceIp && (
<a
href={`http://${p.instanceIp}:${p.port}`}
target="_blank"
rel="noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline"
>
🟢 http://{p.instanceIp}:{p.port}
</a>
)}
<p className="text-xs text-gray-400 dark:text-slate-500 mt-2">
Updated {new Date(p.updatedAt).toLocaleString()}
</p>
</Link>
</motion.div>
))}
</div>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { toast } from "react-toastify";
import { motion } from "motion/react";
export function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
const { refresh } = useAuth();
const navigate = useNavigate();
useEffect(() => {
api.auth.setupStatus().then(res => {
setNeedsSetup(res.ok ? (res.data as any)?.needsSetup : false);
});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (needsSetup && password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
let res;
if (needsSetup) {
res = await api.auth.firstUser(username, password);
} else {
res = await api.auth.login(username, password);
}
setLoading(false);
if (res.ok) {
await refresh();
navigate("/");
} else {
toast.error(res.message || (needsSetup ? "Failed to create account" : "Login failed"));
}
};
if (needsSetup === null) {
return <div className="min-h-screen flex items-center justify-center">
<div className="text-2xl animate-spin"></div>
</div>;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-slate-900 px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-slate-800 shadow-xl rounded-2xl p-8 w-full max-w-sm"
>
<div className="text-center mb-6">
<div className="text-4xl mb-2">🚀</div>
<h1 className="text-2xl font-bold">PR Previews</h1>
{needsSetup ? (
<p className="text-sm text-blue-600 dark:text-blue-400 mt-1 font-medium">Create your admin account</p>
) : (
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">Sign in to manage your previews</p>
)}
</div>
{needsSetup && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-xs text-blue-700 dark:text-blue-300">
This is the first time setup. Create the founder admin account.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={needsSetup ? 8 : undefined}
/>
</div>
{needsSetup && (
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
{loading ? (needsSetup ? "Creating..." : "Signing in...") : (needsSetup ? "Create Admin Account" : "Sign in")}
</button>
</form>
</motion.div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import React, { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { api, openLogsWs } from "../services/api";
import { StatusBadge } from "../components/StatusBadge";
import { LogViewer } from "../components/LogViewer";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { toast } from "react-toastify";
interface Job {
id: number;
type: string;
status: string;
createdAt: string;
startedAt: string | null;
finishedAt: string | null;
error: string | null;
}
interface Preview {
id: number;
prNumber: number;
prTitle: string;
commitSha: string;
status: string;
instanceIp: string | null;
port: number;
logs: string;
createdAt: string;
updatedAt: string;
stoppedAt: string | null;
lastActivityAt: string;
repoOwner: string;
repoName: string;
jobs: Job[];
}
export function PreviewDetail() {
const { id } = useParams<{ id: string }>();
const previewId = parseInt(id || "0", 10);
const navigate = useNavigate();
const [preview, setPreview] = useState<Preview | null>(null);
const [logs, setLogs] = useState("");
const [loading, setLoading] = useState(true);
const [confirmStop, setConfirmStop] = useState(false);
const [stopping, setStopping] = useState(false);
const loadPreview = useCallback(async () => {
const res = await api.previews.get(previewId);
if (res.ok && res.data) {
setPreview(res.data as Preview);
setLogs(res.data.logs || "");
} else if (res.status === 404) {
navigate("/");
}
setLoading(false);
}, [previewId, navigate]);
useEffect(() => {
loadPreview();
const interval = setInterval(loadPreview, 5000);
return () => clearInterval(interval);
}, [loadPreview]);
useEffect(() => {
if (!preview) return;
if (preview.status !== "RUNNING" && preview.status !== "BUILDING" && preview.status !== "PROVISIONING") return;
const ws = openLogsWs(previewId, (msg) => {
if (msg.type === "init") setLogs(msg.logs || "");
else if (msg.type === "append") setLogs(prev => prev + msg.text);
});
return () => { try { ws.close(); } catch {} };
}, [preview?.status, previewId]);
const handleStop = async () => {
setStopping(true);
const res = await api.previews.stop(previewId);
setStopping(false);
setConfirmStop(false);
if (res.ok) toast.success("Stop job enqueued");
else toast.error(res.message || "Failed to enqueue stop");
await loadPreview();
};
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-slate-700 rounded w-64" />
<div className="h-4 bg-gray-200 dark:bg-slate-700 rounded w-96" />
<div className="h-96 bg-gray-200 dark:bg-slate-700 rounded" />
</div>
);
}
if (!preview) return null;
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmStop}
title="Stop Preview"
message="Are you sure you want to stop this preview? The EC2 instance will be terminated."
confirmLabel="Stop Preview"
onConfirm={handleStop}
onCancel={() => setConfirmStop(false)}
danger
/>
<div>
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-2 inline-block">
Back to Previews
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">
{preview.repoOwner}/{preview.repoName} PR #{preview.prNumber}
</h1>
<p className="text-gray-600 dark:text-slate-300 mt-1">{preview.prTitle}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={preview.status as any} />
{preview.status !== "STOPPED" && preview.status !== "IGNORED" && (
<button
onClick={() => setConfirmStop(true)}
disabled={stopping}
className="px-3 py-1.5 text-sm bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60 rounded-lg transition-colors disabled:opacity-50"
>
Stop
</button>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoCard label="Commit" value={preview.commitSha.slice(0, 8)} mono />
<InfoCard label="Port" value={String(preview.port)} />
<InfoCard label="Created" value={new Date(preview.createdAt).toLocaleDateString()} />
<InfoCard label="Last Activity" value={new Date(preview.lastActivityAt).toLocaleString()} />
</div>
{preview.status === "RUNNING" && preview.instanceIp && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4">
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-1">🟢 Preview Live</p>
<a
href={`http://${preview.instanceIp}:${preview.port}`}
target="_blank"
rel="noreferrer"
className="text-green-700 dark:text-green-400 hover:underline font-mono text-sm"
>
http://{preview.instanceIp}:{preview.port}
</a>
</div>
)}
<div>
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer logs={logs} autoScroll maxHeight="600px" />
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Job History</h2>
<div className="space-y-2">
{preview.jobs.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-slate-400">No jobs yet.</p>
) : preview.jobs.map(job => (
<div key={job.id} className="flex items-center gap-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-3">
<span className="text-xs font-mono bg-gray-100 dark:bg-slate-700 px-2 py-0.5 rounded">{job.type}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
job.status === "DONE" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300" :
job.status === "FAILED" ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" :
job.status === "RUNNING" ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300" :
"bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300"
}`}>{job.status}</span>
<span className="text-xs text-gray-500 dark:text-slate-400">{new Date(job.createdAt).toLocaleString()}</span>
{job.error && (
<span className="text-xs text-red-600 dark:text-red-400 truncate max-w-xs" title={job.error}>{job.error}</span>
)}
</div>
))}
</div>
</div>
</div>
);
}
function InfoCard({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-slate-400 mb-1">{label}</p>
<p className={`text-sm font-medium truncate ${mono ? "font-mono" : ""}`}>{value}</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
import { Link } from "react-router-dom";
export function Privacy() {
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>
<h1>Privacy Policy</h1>
<p>This is a self-hosted instance of PR Previews (PP). The following describes what data PP stores and how it is used.</p>
<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>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>
<h2>How We Use Your Data</h2>
<ul>
<li>Your Gitea PAT is used solely to register webhooks, clone repositories, and post preview status comments on PRs.</li>
<li>Your AWS credentials are used solely to provision EC2 instances for previews in your own AWS account.</li>
<li>PP does not have access to data on EC2 instances beyond what it deploys.</li>
<li>No data is shared with third parties.</li>
</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>
<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>
<h2>Analytics & 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>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
import React, { useEffect, useState } from "react";
import { api } from "../services/api";
import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog";
const INSTANCE_TYPES = [
{ value: "t2.micro", label: "t2.micro", cost: "$0.012/hr" },
{ value: "t2.medium", label: "t2.medium", cost: "$0.046/hr" },
{ value: "t3.medium", label: "t3.medium", cost: "$0.042/hr" },
{ value: "t3.large", label: "t3.large", cost: "$0.083/hr" },
{ value: "m5.large", label: "m5.large", cost: "$0.096/hr" },
{ value: "c5.large", label: "c5.large", cost: "$0.085/hr" },
];
interface Repo {
owner: string;
name: string;
fullName: string;
htmlUrl: string;
isEnabled: boolean;
claimedByOther: boolean;
config: any;
}
export function Repos() {
const [repos, setRepos] = useState<Repo[]>([]);
const [loading, setLoading] = useState(true);
const [expandedRepo, setExpandedRepo] = useState<string | null>(null);
const [configs, setConfigs] = useState<Record<string, any>>({});
const [disableConfirm, setDisableConfirm] = useState<{ owner: string; repo: string } | null>(null);
const [togglingRepo, setTogglingRepo] = useState<string | null>(null);
const [savingRepo, setSavingRepo] = useState<string | null>(null);
const load = async () => {
setLoading(true);
const res = await api.repos.list();
if (res.ok) {
const data = (res.data || []) as Repo[];
setRepos(data);
const initial: Record<string, any> = {};
data.forEach(r => {
const key = `${r.owner}/${r.name}`;
initial[key] = r.config || defaultConfig();
});
setConfigs(initial);
} else {
toast.error(res.message || "Failed to load repos");
}
setLoading(false);
};
useEffect(() => { load(); }, []);
function defaultConfig() {
return {
instanceType: "t2.medium",
inactivityHours: 12,
port: 3000,
denyList: [],
envVars: {},
useDockerCompose: false,
composeFilePath: "docker-compose.yml",
aptPackages: [],
setupCommands: [],
buildCommands: [],
postBuildCommands: [],
runCommand: "",
};
}
const handleToggle = async (repo: Repo) => {
const key = `${repo.owner}/${repo.name}`;
if (repo.isEnabled) {
setDisableConfirm({ owner: repo.owner, repo: repo.name });
return;
}
setTogglingRepo(key);
const res = await api.repos.toggle(repo.owner, repo.name, true);
setTogglingRepo(null);
if (res.ok) { toast.success("Repo enabled"); load(); }
else toast.error(res.message || "Failed to enable repo");
};
const handleDisable = async () => {
if (!disableConfirm) return;
const key = `${disableConfirm.owner}/${disableConfirm.repo}`;
setDisableConfirm(null);
setTogglingRepo(key);
const res = await api.repos.toggle(disableConfirm.owner, disableConfirm.repo, false);
setTogglingRepo(null);
if (res.ok) { toast.success("Repo disabled"); load(); }
else toast.error(res.message || "Failed to disable repo");
};
const handleSaveConfig = async (owner: string, name: string) => {
const key = `${owner}/${name}`;
setSavingRepo(key);
const config = configs[key] || defaultConfig();
const res = await api.repos.saveConfig({ owner, repo: name, ...config });
setSavingRepo(null);
if (res.ok) toast.success("Config saved");
else toast.error(res.message || "Failed to save config");
};
const updateConfig = (key: string, field: string, value: any) => {
setConfigs(prev => ({ ...prev, [key]: { ...(prev[key] || defaultConfig()), [field]: value } }));
};
if (loading) {
return <div className="space-y-3">
{[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
</div>;
}
return (
<div className="max-w-3xl space-y-4">
<ConfirmDialog
open={!!disableConfirm}
title="Disable Repo"
message={`Disabling "${disableConfirm?.owner}/${disableConfirm?.repo}" will delete the Gitea webhook. PR preview events will no longer be received.`}
confirmLabel="Disable & Delete Webhook"
onConfirm={handleDisable}
onCancel={() => setDisableConfirm(null)}
danger
/>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Repo Configuration</h1>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> Refresh</button>
</div>
{repos.length === 0 && (
<div className="text-center py-16">
<div className="text-5xl mb-4">🔗</div>
<h2 className="text-lg font-semibold mb-2">No repos found</h2>
<p className="text-gray-500 dark:text-slate-400">Configure your Gitea credentials in Settings first.</p>
</div>
)}
{repos.map(repo => {
const key = `${repo.owner}/${repo.name}`;
const expanded = expandedRepo === key;
const config = configs[key] || defaultConfig();
const isToggling = togglingRepo === key;
return (
<div key={key} className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{repo.fullName}</p>
{repo.claimedByOther && (
<span className="text-xs text-amber-600 dark:text-amber-400">🔒 Claimed by another user</span>
)}
</div>
<div className="flex items-center gap-2">
{!repo.claimedByOther && (
<button
onClick={() => handleToggle(repo)}
disabled={isToggling}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
repo.isEnabled ? "bg-blue-600" : "bg-gray-300 dark:bg-slate-600"
} disabled:opacity-50`}
title={repo.isEnabled ? "Disable previews" : "Enable previews"}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${repo.isEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
)}
{repo.isEnabled && !repo.claimedByOther && (
<button
onClick={() => setExpandedRepo(expanded ? null : key)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{expanded ? "▲ Hide" : "▼ Config"}
</button>
)}
</div>
</div>
{expanded && (
<div className="border-t border-gray-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>Instance Type</label>
<select value={config.instanceType} onChange={e => updateConfig(key, "instanceType", e.target.value)} className={inputCls}>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{config.instanceType === "custom" && (
<input type="text" placeholder="Custom instance type" className={`${inputCls} mt-1`}
onChange={e => updateConfig(key, "instanceType", e.target.value)} />
)}
</div>
<div>
<label className={labelCls}>App Port (default: 3000)</label>
<input type="number" value={config.port} onChange={e => updateConfig(key, "port", Number(e.target.value))} className={inputCls} min={1} max={65535} />
</div>
</div>
<div>
<label className={labelCls}>Inactivity Kill Timer: {config.inactivityHours}h</label>
<input type="range" min={0.5} max={72} step={0.5} value={config.inactivityHours}
onChange={e => updateConfig(key, "inactivityHours", Number(e.target.value))}
className="w-full mt-1" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0.5h</span><span>12h</span><span>72h</span>
</div>
</div>
<div>
<label className={labelCls}>Deny List (comma-separated usernames)</label>
<input type="text"
value={config.denyList.join(", ")}
onChange={e => updateConfig(key, "denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))}
className={inputCls}
placeholder="dependabot, renovate-bot"
/>
</div>
<div>
<label className={labelCls}>Apt Packages (space-separated)</label>
<input type="text"
value={config.aptPackages.join(" ")}
onChange={e => updateConfig(key, "aptPackages", e.target.value.split(" ").filter(Boolean))}
className={inputCls}
placeholder="python3 ffmpeg"
/>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<input type="checkbox" id={`compose-${key}`} checked={config.useDockerCompose}
onChange={e => updateConfig(key, "useDockerCompose", e.target.checked)} className="rounded" />
<label htmlFor={`compose-${key}`} className="text-sm font-medium">Use Docker Compose</label>
</div>
{config.useDockerCompose ? (
<div>
<label className={labelCls}>Compose File Path</label>
<input type="text" value={config.composeFilePath || "docker-compose.yml"}
onChange={e => updateConfig(key, "composeFilePath", e.target.value)}
className={inputCls} placeholder="docker-compose.yml" />
</div>
) : (
<div className="space-y-3">
<CommandList label="Build Commands" value={config.buildCommands}
onChange={v => updateConfig(key, "buildCommands", v)} />
<CommandList label="Post-Build Commands (optional)" value={config.postBuildCommands}
onChange={v => updateConfig(key, "postBuildCommands", v)} />
<div>
<label className={labelCls}>Run Command</label>
<input type="text" value={config.runCommand || ""}
onChange={e => updateConfig(key, "runCommand", e.target.value)}
className={inputCls} placeholder="node dist/index.js" />
</div>
</div>
)}
</div>
<CommandList label="Setup Commands (run once on first provision)"
value={config.setupCommands}
onChange={v => updateConfig(key, "setupCommands", v)} />
<EnvVarsEditor value={config.envVars || {}}
onChange={v => updateConfig(key, "envVars", v)} />
<div className="flex justify-end">
<button
onClick={() => handleSaveConfig(repo.owner, repo.name)}
disabled={savingRepo === key}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{savingRepo === key ? "Saving..." : "Save Config"}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) {
return (
<div>
<label className={labelCls}>{label}</label>
<div className="space-y-1">
{value.map((cmd, i) => (
<div key={i} className="flex gap-2">
<input type="text" value={cmd}
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }}
className={inputCls} placeholder="npm run build" />
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"></button>
</div>
))}
<button onClick={() => onChange([...value, ""])} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
+ Add command
</button>
</div>
</div>
);
}
function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
const entries = Object.entries(value);
const addEntry = () => onChange({ ...value, "": "" });
const updateEntry = (oldKey: string, newKey: string, newVal: string) => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (k === oldKey) next[newKey] = newVal;
else next[k] = v;
}
onChange(next);
};
const removeEntry = (k: string) => {
const next = { ...value };
delete next[k];
onChange(next);
};
return (
<div>
<label className={labelCls}>Environment Variables</label>
<div className="space-y-1">
{entries.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<input type="text" value={k} placeholder="KEY"
onChange={e => updateEntry(k, e.target.value, v)}
className={`${inputCls} w-1/3 font-mono text-xs`} />
<input type="password" value={v} placeholder="value"
onChange={e => updateEntry(k, k, e.target.value)}
className={`${inputCls} flex-1 font-mono text-xs`} />
<button onClick={() => removeEntry(k)} className="text-red-500 hover:text-red-700 px-2"></button>
</div>
))}
<button onClick={addEntry} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
+ Add variable
</button>
</div>
</div>
);
}
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";
+290
View File
@@ -0,0 +1,290 @@
import React, { useState, useEffect } from "react";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog";
const AWS_REGIONS = [
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1",
"sa-east-1", "ca-central-1", "me-south-1", "af-south-1",
];
export function Settings() {
const { user, refresh } = useAuth();
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [username, setUsername] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [giteaUrl, setGiteaUrl] = useState("");
const [giteaUsername, setGiteaUsername] = useState("");
const [giteaPAT, setGiteaPAT] = useState("");
const [awsKeyId, setAwsKeyId] = useState("");
const [awsSecret, setAwsSecret] = useState("");
const [awsRegion, setAwsRegion] = useState("us-east-1");
const [webhookUrl, setWebhookUrl] = useState("");
const [webhookSecret, setWebhookSecret] = useState("");
const [confirmRotate, setConfirmRotate] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
useEffect(() => {
api.user.settings().then(res => {
if (res.ok && res.data) {
const d = res.data;
setSettings(d);
setUsername(d.username || "");
setGiteaUrl(d.giteaInstanceUrl || "");
setGiteaUsername(d.giteaUsername || "");
setWebhookUrl(d.webhookUrl || "");
}
});
api.user.getWebhookSecret().then(res => {
if (res.ok && res.data) setWebhookSecret(res.data.token);
});
setLoading(false);
}, []);
const save = async (key: string, fn: () => Promise<any>) => {
setSaving(key);
const res = await fn();
setSaving(null);
if (res.ok) {
toast.success(res.message || "Saved");
refresh();
} else {
toast.error(res.message || "Failed to save");
}
};
const handleRotateSecret = async () => {
setConfirmRotate(false);
setSaving("rotate");
const res = await api.user.regenerateWebhookSecret();
setSaving(null);
if (res.ok && res.data) {
setWebhookSecret(res.data.token);
toast.success(res.message || "Secret regenerated");
} else {
toast.error(res.message || "Failed to regenerate");
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.info(`${label} copied to clipboard`);
};
if (loading) return <div className="animate-pulse h-96 bg-gray-100 dark:bg-slate-800 rounded-xl" />;
return (
<div className="max-w-2xl space-y-8">
<ConfirmDialog
open={confirmRotate}
title="Regenerate Webhook Secret"
message="This will generate a new secret and update all registered Gitea webhooks. The old secret will become invalid immediately."
confirmLabel="Regenerate"
onConfirm={handleRotateSecret}
onCancel={() => setConfirmRotate(false)}
danger
/>
<h1 className="text-2xl font-bold">Account Settings</h1>
{/* Username */}
<Section title="Username">
<div className="flex gap-2">
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className={inputCls}
placeholder="Username"
/>
<button
onClick={() => save("username", () => api.user.updateUsername(username))}
disabled={saving === "username"}
className={btnCls}
>
{saving === "username" ? "Saving..." : "Save"}
</button>
</div>
</Section>
{/* Password */}
<Section title="Change Password">
<div className="space-y-2">
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
className={inputCls}
placeholder="Current password"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className={inputCls}
placeholder="New password"
/>
<button
onClick={() => save("password", () => api.user.updatePassword(currentPassword, newPassword))}
disabled={saving === "password"}
className={btnCls}
>
{saving === "password" ? "Saving..." : "Update Password"}
</button>
</div>
</Section>
{/* Gitea */}
<Section title="Gitea Connection">
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
PP will post PR comments as your Gitea account (@{giteaUsername || "username"}). Make sure your PAT has 'issue' write permission.
</p>
<div className="space-y-2">
<input
type="url"
value={giteaUrl}
onChange={e => setGiteaUrl(e.target.value)}
className={inputCls}
placeholder="https://gitea.example.com"
/>
<input
type="text"
value={giteaUsername}
onChange={e => setGiteaUsername(e.target.value)}
className={inputCls}
placeholder="Gitea username"
/>
<div className="relative">
<input
type="password"
value={giteaPAT}
onChange={e => setGiteaPAT(e.target.value)}
className={inputCls}
placeholder={settings?.giteaPatSet ? "•••••••• (set — enter new value to update)" : "Personal Access Token"}
/>
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1">
Required PAT scopes: <code>repository</code> (read), <code>issue</code> (write), <code>admin:repo_hook</code>
</p>
</div>
<button
onClick={() => save("gitea", () => api.user.updateGitea({ giteaInstanceUrl: giteaUrl, giteaUsername, giteaPAT: giteaPAT || undefined }))}
disabled={saving === "gitea"}
className={btnCls}
>
{saving === "gitea" ? "Connecting..." : "Save & Validate"}
</button>
</div>
</Section>
{/* AWS */}
<Section title="AWS Credentials">
<div className="space-y-2">
<input
type="text"
value={awsKeyId}
onChange={e => setAwsKeyId(e.target.value)}
className={inputCls}
placeholder={settings?.awsAccessKeyId ? `${settings.awsAccessKeyId} (set)` : "AWS Access Key ID"}
/>
<input
type="password"
value={awsSecret}
onChange={e => setAwsSecret(e.target.value)}
className={inputCls}
placeholder="AWS Secret Access Key"
/>
<select
value={awsRegion}
onChange={e => setAwsRegion(e.target.value)}
className={inputCls}
>
{AWS_REGIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
<p className="text-xs font-medium mb-2">Required IAM Permissions:</p>
<pre className="text-xs text-gray-600 dark:text-slate-300 overflow-auto">{IAM_POLICY}</pre>
<button onClick={() => copyToClipboard(IAM_POLICY, "IAM policy")} className="text-xs text-blue-600 dark:text-blue-400 mt-1 hover:underline">
Copy IAM policy
</button>
</div>
<button
onClick={() => save("aws", () => api.user.updateAws({ awsAccessKeyId: awsKeyId, awsSecretAccessKey: awsSecret, awsRegion }))}
disabled={saving === "aws"}
className={btnCls}
>
{saving === "aws" ? "Validating..." : "Save & Validate"}
</button>
</div>
</Section>
{/* Webhook */}
<Section title="Webhook">
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 dark:text-slate-400 block mb-1">Webhook URL (Target URL in Gitea)</label>
<div className="flex gap-2">
<input type="text" readOnly value={webhookUrl} className={`${inputCls} bg-gray-50 dark:bg-slate-700/50 font-mono text-xs`} />
<button onClick={() => copyToClipboard(webhookUrl, "Webhook URL")} className={btnSecCls}>Copy</button>
</div>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-slate-400 block mb-1">Webhook Secret</label>
<div className="flex gap-2">
<input type="text" readOnly value={webhookSecret} className={`${inputCls} bg-gray-50 dark:bg-slate-700/50 font-mono text-xs`} />
<button onClick={() => copyToClipboard(webhookSecret, "Webhook secret")} className={btnSecCls}>Copy</button>
<button onClick={() => setConfirmRotate(true)} disabled={saving === "rotate"} className="px-3 py-2 text-sm rounded-lg bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-200 transition-colors">
Rotate
</button>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-slate-400">
PP auto-registers per-repo webhooks when you enable a repo. These credentials are for reference only.
</p>
</div>
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-5">
<h2 className="text-base font-semibold mb-4">{title}</h2>
{children}
</div>
);
}
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 btnSecCls = "px-3 py-2 text-sm rounded-lg bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300 transition-colors shrink-0";
const IAM_POLICY = `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups",
"ec2:ImportKeyPair",
"ec2:DeleteKeyPair",
"ec2:CreateTags",
"sts:GetCallerIdentity"
],
"Resource": "*"
}]
}`;
+227
View File
@@ -0,0 +1,227 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { toast } from "react-toastify";
import { motion, AnimatePresence } from "motion/react";
const STEPS = ["Welcome", "Gitea Connection", "AWS Setup", "Your Webhook", "Enable a Repo", "Done"];
const AWS_REGIONS = [
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
"eu-west-1", "eu-west-2", "eu-central-1",
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1",
];
export function SetupWizard() {
const [step, setStep] = useState(0);
const { user, refresh } = useAuth();
const navigate = useNavigate();
const [giteaUrl, setGiteaUrl] = useState(user?.giteaInstanceUrl || "");
const [giteaUsername, setGiteaUsername] = useState(user?.giteaUsername || "");
const [giteaPAT, setGiteaPAT] = useState("");
const [awsKeyId, setAwsKeyId] = useState("");
const [awsSecret, setAwsSecret] = useState("");
const [awsRegion, setAwsRegion] = useState("us-east-1");
const [saving, setSaving] = useState(false);
const [webhookUrl, setWebhookUrl] = useState("");
const [webhookSecret, setWebhookSecret] = useState("");
const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
const skip = () => navigate("/");
const handleGiteaNext = async () => {
setSaving(true);
const res = await api.user.updateGitea({ giteaInstanceUrl: giteaUrl, giteaUsername, giteaPAT });
setSaving(false);
if (res.ok) {
toast.success("Gitea connected!");
refresh();
next();
} else {
toast.error(res.message || "Failed to connect Gitea");
}
};
const handleAwsNext = async () => {
setSaving(true);
const res = await api.user.updateAws({ awsAccessKeyId: awsKeyId, awsSecretAccessKey: awsSecret, awsRegion });
setSaving(false);
if (res.ok) {
toast.success("AWS connected!");
refresh();
const secretRes = await api.user.getWebhookSecret();
if (secretRes.ok && secretRes.data) {
setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`);
setWebhookSecret(secretRes.data.token);
}
next();
} else {
toast.error(res.message || "Failed to connect AWS");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-slate-900 px-4">
<div className="bg-white dark:bg-slate-800 shadow-xl rounded-2xl p-8 w-full max-w-lg">
{/* Progress */}
<div className="mb-8">
<div className="flex items-center gap-1">
{STEPS.map((s, i) => (
<React.Fragment key={i}>
<div className={`h-1.5 flex-1 rounded-full transition-colors ${i <= step ? "bg-blue-600" : "bg-gray-200 dark:bg-slate-600"}`} />
</React.Fragment>
))}
</div>
<p className="text-xs text-gray-500 dark:text-slate-400 mt-2">Step {step + 1} of {STEPS.length}: {STEPS[step]}</p>
</div>
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
{step === 0 && (
<div className="text-center space-y-4">
<div className="text-5xl">🚀</div>
<h2 className="text-2xl font-bold">Welcome to PR Previews</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm">
PP automatically provisions EC2 instances, builds your code, and posts live preview URLs on every pull request.
</p>
<p className="text-sm text-gray-500 dark:text-slate-400">Let's get you set up in a few steps.</p>
<div className="flex gap-3 justify-center mt-4">
<button onClick={next} className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Get Started →
</button>
<button onClick={skip} className="px-4 py-2.5 text-sm text-gray-500 dark:text-slate-400 hover:underline">
Skip wizard
</button>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Gitea Connection</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">Connect to your Gitea instance so PP can register webhooks and post comments.</p>
<input type="url" value={giteaUrl} onChange={e => setGiteaUrl(e.target.value)}
className={inputCls} placeholder="https://gitea.example.com" />
<input type="text" value={giteaUsername} onChange={e => setGiteaUsername(e.target.value)}
className={inputCls} placeholder="Gitea username" />
<input type="password" value={giteaPAT} onChange={e => setGiteaPAT(e.target.value)}
className={inputCls} placeholder="Personal Access Token" />
<p className="text-xs text-gray-400 dark:text-slate-500">
Required PAT scopes: <code>repository</code>, <code>issue</code>, <code>admin:repo_hook</code>
</p>
<div className="flex gap-3">
<button onClick={handleGiteaNext} disabled={saving || !giteaUrl || !giteaUsername || !giteaPAT}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50">
{saving ? "Validating..." : "Connect & Next →"}
</button>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">AWS Setup</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">PP will launch EC2 instances in your AWS account to host previews.</p>
<input type="text" value={awsKeyId} onChange={e => setAwsKeyId(e.target.value)}
className={inputCls} placeholder="AWS Access Key ID" />
<input type="password" value={awsSecret} onChange={e => setAwsSecret(e.target.value)}
className={inputCls} placeholder="AWS Secret Access Key" />
<select value={awsRegion} onChange={e => setAwsRegion(e.target.value)} className={inputCls}>
{AWS_REGIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<details className="text-xs">
<summary className="cursor-pointer text-blue-600 dark:text-blue-400">View required IAM permissions</summary>
<pre className="mt-2 bg-gray-50 dark:bg-slate-700 rounded p-3 overflow-auto text-gray-700 dark:text-slate-300">
{`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["ec2:RunInstances","ec2:TerminateInstances",
"ec2:DescribeInstances","ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups","ec2:ImportKeyPair",
"ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"],
"Resource": "*"
}]
}`}
</pre>
</details>
<div className="flex gap-3">
<button onClick={handleAwsNext} disabled={saving || !awsKeyId || !awsSecret}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50">
{saving ? "Validating..." : "Connect & Next →"}
</button>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Your Webhook</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">
PP auto-registers webhooks when you enable a repo. These are for reference:
</p>
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500 block mb-1">Webhook URL (Target URL in Gitea)</label>
<input type="text" readOnly value={webhookUrl || `${window.location.origin}/webhook/${user?.id}`}
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Webhook Secret</label>
<input type="text" readOnly value={webhookSecret || "(loading...)"}
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div>
</div>
<button onClick={next} className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Next →
</button>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Enable Your First Repo</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">
Go to the Repos page to enable previews for a repository. PP will automatically register the webhook.
</p>
<div className="flex gap-3">
<a href="/repos"
className="flex-1 py-2.5 text-center bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Repos →
</a>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
</div>
)}
{step === 5 && (
<div className="text-center space-y-4">
<div className="text-5xl">🎉</div>
<h2 className="text-2xl font-bold">You're all set!</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm">
Open a pull request on an enabled repo and PP will provision a preview automatically.
</p>
<button onClick={() => navigate("/")} className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Dashboard
</button>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
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";
+74
View File
@@ -0,0 +1,74 @@
const BASE = "/api";
async function request<T = any>(
method: string,
path: string,
body?: any,
): Promise<{ ok: boolean; data?: T; message?: string; status: number }> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
});
let json: any = {};
try {
json = await res.json();
} catch {}
return { ok: res.ok, data: json.data, message: json.message, status: res.status };
}
export const api = {
auth: {
login: (username: string, password: string) => request("POST", "/auth/login", { username, password }),
logout: () => request("POST", "/auth/logout"),
me: () => request("GET", "/auth/me"),
setupStatus: () => request("GET", "/auth/setup-status"),
firstUser: (username: string, password: string) => request("POST", "/auth/first-user", { username, password }),
},
user: {
settings: () => request("GET", "/user/settings"),
updateUsername: (username: string) => request("PATCH", "/user/username", { username }),
updatePassword: (currentPassword: string, newPassword: string) =>
request("PATCH", "/user/password", { currentPassword, newPassword }),
updateGitea: (data: { giteaInstanceUrl: string; giteaUsername: string; giteaPAT?: string }) =>
request("PUT", "/user/gitea", data),
updateAws: (data: { awsAccessKeyId: string; awsSecretAccessKey: string; awsRegion: string }) =>
request("PUT", "/user/aws", data),
getWebhookSecret: () => request("GET", "/user/webhook-secret"),
regenerateWebhookSecret: () => request("POST", "/user/webhook-secret/regenerate"),
},
repos: {
list: () => request("GET", "/repos"),
getConfig: (owner: string, repo: string) => request("GET", `/repos/${owner}/${repo}/config`),
saveConfig: (data: any) => request("POST", "/repos/config", data),
toggle: (owner: string, repo: string, enabled: boolean) => request("POST", "/repos/toggle", { owner, repo, enabled }),
},
previews: {
list: () => request("GET", "/previews"),
get: (id: number) => request("GET", `/previews/${id}`),
stop: (id: number) => request("POST", `/previews/${id}/stop`),
},
admin: {
listUsers: () => request("GET", "/admin/users"),
createUser: (username: string, password: string) => request("POST", "/admin/users", { username, password }),
updateUser: (id: number, data: any) => request("PATCH", `/admin/users/${id}`, data),
deleteUser: (id: number) => request("DELETE", `/admin/users/${id}`),
getSettings: () => request("GET", "/admin/settings"),
updateSettings: (data: any) => request("PUT", "/admin/settings", data),
listPreviews: () => request("GET", "/admin/previews"),
stopPreview: (id: number) => request("POST", `/admin/previews/${id}/stop`),
},
};
export function openLogsWs(previewId: number, onMessage: (msg: any) => void, onClose?: () => void): WebSocket {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/api/previews/${previewId}/logs`);
ws.onmessage = (e) => {
try { onMessage(JSON.parse(e.data)); } catch {}
};
ws.onclose = onClose || (() => {});
return ws;
}