Save this API key now — it won't be shown again.
@@ -210,19 +230,19 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
)}
-
diff --git a/UI/src/components/ChangeRenderer.tsx b/UI/src/components/ChangeRenderer.tsx
index 89b71a0..7173df2 100644
--- a/UI/src/components/ChangeRenderer.tsx
+++ b/UI/src/components/ChangeRenderer.tsx
@@ -1,9 +1,15 @@
+import { useState } from "react";
import type { Change } from "../api/types";
+import { WrapIcon } from "./icons";
-function DiffView({ content }: { content: string }) {
+function DiffView({ content, wrap }: { content: string; wrap: boolean }) {
const lines = content.split("\n");
return (
-
+
{lines.map((line, i) => {
let cls = "";
@@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) {
else if (line.startsWith("-")) cls = "diff-del";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
return (
-
+
{line || " "}
);
@@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null {
return `${pct > 0 ? "+" : ""}${pct}%`;
}
-function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
- const delta =
- contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
+function BeforeAfter({
+ before,
+ after,
+ contentType,
+}: {
+ before: unknown;
+ after: unknown;
+ contentType?: string | null;
+}) {
+ const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
return (
-
-
{fmtValue(before)}
+
+ {fmtValue(before)}
→
- {fmtValue(after)}
+ {fmtValue(after)}
{delta && {delta} }
{contentType && ({contentType}) }
@@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u
}
function typeChip(label: string, color: string) {
- return
{label} ;
+ return
{label} ;
}
export function ChangeCard({ change, index }: { change: Change; index: number }) {
+ const [wrap, setWrap] = useState(false);
+
return (
-
-
-
#{index + 1}
+
+
+
#{index + 1}
+
+
+ {change.type === "unified_diff" && (
+ <>
+ {typeChip("diff", "bg-primary-dim/40 text-primary")}
+ {change.path}
+ >
+ )}
+ {change.type === "config" && (
+ <>
+ {typeChip("config", "bg-accent/15 text-accent")}
+ {change.path}
+ >
+ )}
+ {change.type === "custom" && (
+ <>
+ {typeChip("custom", "bg-changes/15 text-changes")}
+ {change.label}
+ >
+ )}
+
+
+ {/* Wrapping beats horizontal scrolling for long lines on a phone. */}
{change.type === "unified_diff" && (
- <>
- {typeChip("diff", "bg-primary-dim/40 text-primary")}
-
{change.path}
- >
- )}
- {change.type === "config" && (
- <>
- {typeChip("config", "bg-accent/15 text-accent")}
-
{change.path}
- >
- )}
- {change.type === "custom" && (
- <>
- {typeChip("custom", "bg-changes/15 text-changes")}
-
{change.label}
- >
+
setWrap((w) => !w)}
+ aria-pressed={wrap}
+ title={wrap ? "Disable line wrapping" : "Wrap long lines"}
+ className={`tap-sm -mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition ${
+ wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted"
+ }`}
+ >
+
+
)}
- {change.type === "unified_diff" &&
}
+
+ {change.type === "unified_diff" &&
}
{change.type === "config" && (
)}
diff --git a/UI/src/components/CodeBlock.tsx b/UI/src/components/CodeBlock.tsx
index f29855b..14a6b8a 100644
--- a/UI/src/components/CodeBlock.tsx
+++ b/UI/src/components/CodeBlock.tsx
@@ -1,30 +1,41 @@
import { useState } from "react";
+import { CheckIcon, CopyIcon } from "./icons";
export function CodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false);
+
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
- /* ignore */
+ /* clipboard unavailable (insecure origin) — nothing useful to do */
}
};
+
return (
{language && (
-
+
{language}
)}
- {copied ? "Copied!" : "Copy"}
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
-
+
{code}
diff --git a/UI/src/components/ConfirmSheet.tsx b/UI/src/components/ConfirmSheet.tsx
new file mode 100644
index 0000000..7df4cfd
--- /dev/null
+++ b/UI/src/components/ConfirmSheet.tsx
@@ -0,0 +1,52 @@
+import { Sheet } from "./Sheet";
+import { Button } from "./ui";
+
+/**
+ * Replaces `window.confirm` for destructive actions — the native dialog is a
+ * hard stop that looks nothing like the app on a phone.
+ */
+export function ConfirmSheet({
+ open,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel = "Confirm",
+ danger,
+ loading,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ danger?: boolean;
+ loading?: boolean;
+}) {
+ return (
+
+
+ Cancel
+
+
+ {confirmLabel}
+
+ >
+ }
+ >
+ {message}
+
+ );
+}
diff --git a/UI/src/components/DecisionModal.tsx b/UI/src/components/DecisionModal.tsx
index 76574f4..85ae50b 100644
--- a/UI/src/components/DecisionModal.tsx
+++ b/UI/src/components/DecisionModal.tsx
@@ -9,7 +9,10 @@ type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
const MAX = 500;
-const meta: Record
= {
+const meta: Record<
+ Decision,
+ { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }
+> = {
APPROVE: {
title: "Approve request",
verb: "Approve",
@@ -46,18 +49,24 @@ export function DecisionModal({
}) {
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(false);
+ // Held past `decision` going null so the sheet can animate out with its
+ // copy intact instead of blanking mid-dismiss.
+ const [shown, setShown] = useState(decision);
useEffect(() => {
- setComment("");
+ if (decision) {
+ setShown(decision);
+ setComment("");
+ }
}, [decision]);
- if (!decision) return null;
- const m = meta[decision];
+ if (!shown) return null;
+ const m = meta[shown];
const tooLong = comment.length > MAX;
const missingRequired = m.commentRequired && comment.trim().length === 0;
const submit = async () => {
- if (tooLong || missingRequired) return;
+ if (!decision || tooLong || missingRequired) return;
setLoading(true);
try {
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
@@ -79,10 +88,16 @@ export function DecisionModal({
title={m.title}
footer={
<>
-
+
Cancel
-
+
{m.verb}
>
@@ -90,19 +105,22 @@ export function DecisionModal({
>
-
{request.title}
+
{request.title}
- {request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
- {request.agent?.name}
+ {request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name}
{m.blurb}
-
+
Comment {m.commentRequired ? (required) : "(optional)"}
- MAX * 0.8 ? "text-pending" : "text-faint"}`}>
+ MAX * 0.8 ? "text-pending" : "text-faint"
+ }`}
+ >
{comment.length}/{MAX}
@@ -110,9 +128,11 @@ export function DecisionModal({
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
- autoFocus
+ // Only steal focus (and raise the keyboard) when a comment is
+ // actually needed to submit.
+ autoFocus={m.commentRequired}
placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"}
- className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${
+ className={`w-full rounded-lg border bg-bg px-3 py-2 text-base text-text outline-none focus:border-primary sm:text-sm ${
tooLong ? "border-rejected" : "border-border-strong"
}`}
/>
diff --git a/UI/src/components/Layout.tsx b/UI/src/components/Layout.tsx
index 4536a69..5ae252b 100644
--- a/UI/src/components/Layout.tsx
+++ b/UI/src/components/Layout.tsx
@@ -1,7 +1,18 @@
-import { ReactNode, useState } from "react";
+import { ReactNode, useEffect, useRef, useState } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
+import { useNotifications } from "../context/NotificationsContext";
import { NotificationBell } from "./NotificationBell";
+import { Sheet } from "./Sheet";
+import {
+ AgentsIcon,
+ BellIcon,
+ HomeIcon,
+ LogOutIcon,
+ RequestsIcon,
+ SettingsIcon,
+ ShieldIcon,
+} from "./icons";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
@@ -10,18 +21,27 @@ const navItems = [
{ to: "/settings", label: "Settings" },
];
+const tabs = [
+ { to: "/", label: "Home", Icon: HomeIcon, end: true },
+ { to: "/requests", label: "Requests", Icon: RequestsIcon },
+ { to: "/agents", label: "Agents", Icon: AgentsIcon },
+ { to: "/notifications", label: "Alerts", Icon: BellIcon, badge: true },
+ { to: "/settings", label: "Settings", Icon: SettingsIcon },
+];
+
export function Layout({ children }: { children: ReactNode }) {
- const { user, logout } = useAuth();
- const navigate = useNavigate();
- const [menuOpen, setMenuOpen] = useState(false);
+ const { user } = useAuth();
return (
-
-
-
+
+
+
-
-
✅
+
+
PatchPass
@@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) {
-
-
-
setMenuOpen((o) => !o)}
- className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
- >
-
- {user?.display_name?.[0]?.toUpperCase()}
-
- {user?.display_name}
-
- {menuOpen && (
-
setMenuOpen(false)}
- >
- setMenuOpen(false)}
- >
- Settings
-
- setMenuOpen(false)}
- >
- Notifications
-
- {user?.role === "ADMIN" && (
- setMenuOpen(false)}
- >
- Admin
-
- )}
- {
- await logout();
- navigate("/login");
- }}
- className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
- >
- Log out
-
-
- )}
+ {/* Below md the bell is replaced by the Alerts tab. */}
+
+
+
- {/* mobile nav */}
-
- {navItems.map((item) => (
-
- `whitespace-nowrap rounded-lg px-2 py-1.5 text-sm font-medium ${
- isActive ? "bg-surface-raised text-text" : "text-muted"
- }`
- }
- >
- {item.label}
-
- ))}
-
-
{children}
+
{children}
-
-
+
+
Privacy Policy
- ·
-
+ ·
+
Terms of Service
- ·
- PatchPass — review changes, approve intent, let agents proceed.
+
+ ·
+
+
+ PatchPass — review changes, approve intent, let agents proceed.
+
+
+ {/* Keeps the last of the page clear of the fixed tab bar. */}
+
+
+
);
}
+
+function BottomTabs() {
+ const { unreadCount } = useNotifications();
+
+ return (
+
+
+ {tabs.map(({ to, label, Icon, end, badge }) => (
+
+ {({ isActive }) => (
+
+
+
+ {badge && unreadCount > 0 && (
+
+ {unreadCount > 9 ? "9+" : unreadCount}
+
+ )}
+
+ {label}
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function AccountMenu() {
+ const { user, logout } = useAuth();
+ const navigate = useNavigate();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", onDown);
+ return () => document.removeEventListener("mousedown", onDown);
+ }, [open]);
+
+ const signOut = async () => {
+ setOpen(false);
+ await logout();
+ navigate("/login");
+ };
+
+ const initial = user?.display_name?.[0]?.toUpperCase() ?? "?";
+
+ return (
+
+
setOpen((o) => !o)}
+ className="tap-sm flex min-h-11 items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
+ aria-label="Account menu"
+ aria-expanded={open}
+ >
+
+ {initial}
+
+ {user?.display_name}
+
+
+ {/* Desktop: dropdown. */}
+ {open && (
+
+ setOpen(false)}>
+ Settings
+
+ {user?.role === "ADMIN" && (
+ setOpen(false)} accent>
+ Admin
+
+ )}
+
+ Log out
+
+
+ )}
+
+ {/* Mobile: bottom sheet, which is reachable one-handed. */}
+
+
setOpen(false)} padded={false}>
+
+
+ {initial}
+
+
+
{user?.display_name}
+
@{user?.username}
+
+
+
+ }
+ label="Settings"
+ onClick={() => {
+ setOpen(false);
+ navigate("/settings");
+ }}
+ />
+ {user?.role === "ADMIN" && (
+ }
+ label="Admin"
+ accent
+ onClick={() => {
+ setOpen(false);
+ navigate("/admin");
+ }}
+ />
+ )}
+ }
+ label="Log out"
+ danger
+ onClick={signOut}
+ />
+
+
+
+
+ );
+}
+
+function MenuLink({
+ to,
+ onClick,
+ accent,
+ children,
+}: {
+ to: string;
+ onClick: () => void;
+ accent?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SheetRow({
+ icon,
+ label,
+ onClick,
+ accent,
+ danger,
+}: {
+ icon: ReactNode;
+ label: string;
+ onClick: () => void;
+ accent?: boolean;
+ danger?: boolean;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/UI/src/components/Modal.tsx b/UI/src/components/Modal.tsx
index 8f8f01e..4a45415 100644
--- a/UI/src/components/Modal.tsx
+++ b/UI/src/components/Modal.tsx
@@ -1,5 +1,11 @@
-import { ReactNode, useEffect } from "react";
+import { ReactNode } from "react";
+import { Sheet } from "./Sheet";
+/**
+ * Kept as the app-wide dialog entry point. Rendering delegates to {@link Sheet},
+ * which is a drag-dismissable bottom sheet on phones and a centred dialog above
+ * the `sm` breakpoint.
+ */
export function Modal({
open,
onClose,
@@ -17,43 +23,9 @@ export function Modal({
footer?: ReactNode;
wide?: boolean;
}) {
- useEffect(() => {
- if (!open) return;
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
- };
- window.addEventListener("keydown", onKey);
- document.body.style.overflow = "hidden";
- return () => {
- window.removeEventListener("keydown", onKey);
- document.body.style.overflow = "";
- };
- }, [open, onClose, onSubmit]);
-
- if (!open) return null;
-
return (
-
-
-
-
-
{children}
- {footer &&
{footer}
}
-
-
+
+ {children}
+
);
}
diff --git a/UI/src/components/NotificationBell.tsx b/UI/src/components/NotificationBell.tsx
index 0787519..ca0d173 100644
--- a/UI/src/components/NotificationBell.tsx
+++ b/UI/src/components/NotificationBell.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext";
+import { BellIcon } from "./icons";
import { relativeTime } from "../utils";
export function NotificationBell() {
@@ -21,25 +22,19 @@ export function NotificationBell() {
setOpen((o) => !o)}
- className="relative rounded-lg p-2 text-muted transition hover:bg-surface-raised hover:text-text"
+ className="relative flex h-10 w-10 items-center justify-center rounded-lg text-muted transition hover:bg-surface-raised hover:text-text"
aria-label="Notifications"
>
-
-
-
+
{unreadCount > 0 && (
-
+
{unreadCount > 99 ? "99+" : unreadCount}
)}
{open && (
-
+
Notifications
diff --git a/UI/src/components/ScrollRow.tsx b/UI/src/components/ScrollRow.tsx
new file mode 100644
index 0000000..7bbeb49
--- /dev/null
+++ b/UI/src/components/ScrollRow.tsx
@@ -0,0 +1,63 @@
+import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
+
+const FADE = 28; // px of taper at each overflowing edge
+
+/**
+ * Horizontally scrollable row of chips/tabs. Fades whichever edge still has
+ * content behind it, so a clipped item reads as "scroll for more" rather than
+ * as a layout bug — and contains its overscroll so a sideways swipe never
+ * triggers the browser's back gesture.
+ */
+export function ScrollRow({
+ children,
+ className = "",
+ wrapperClassName = "",
+ bleed = true,
+}: {
+ children: ReactNode;
+ /** Applied to the scrolling element (layout of the items). */
+ className?: string;
+ /** Applied to the positioned wrapper (borders, margins). */
+ wrapperClassName?: string;
+ /** Extend to the screen edges on phones so the row reads as scrollable. */
+ bleed?: boolean;
+}) {
+ const ref = useRef
(null);
+ const [edges, setEdges] = useState({ start: false, end: false });
+
+ const update = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ const max = el.scrollWidth - el.clientWidth;
+ setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
+ }, []);
+
+ useEffect(() => {
+ update();
+ const el = ref.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ for (const child of Array.from(el.children)) ro.observe(child);
+ return () => ro.disconnect();
+ }, [update]);
+
+ const mask = `linear-gradient(to right, ${
+ edges.start ? `transparent 0, black ${FADE}px` : "black 0"
+ }, ${edges.end ? `black calc(100% - ${FADE}px), transparent 100%` : "black 100%"})`;
+
+ return (
+
+ );
+}
diff --git a/UI/src/components/Sheet.tsx b/UI/src/components/Sheet.tsx
new file mode 100644
index 0000000..990af61
--- /dev/null
+++ b/UI/src/components/Sheet.tsx
@@ -0,0 +1,188 @@
+import { ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { useIsMobile } from "../hooks/useMediaQuery";
+import { useScrollLock } from "../hooks/useScrollLock";
+import { CloseIcon } from "./icons";
+
+const EXIT_MS = 240;
+
+/**
+ * Responsive overlay primitive: a drag-dismissable bottom sheet on phones, a
+ * centred dialog from `sm` up. Everything modal in the app renders through this
+ * so the dismiss gesture, scroll lock and safe-area handling stay consistent.
+ */
+export function Sheet({
+ open,
+ onClose,
+ onSubmit,
+ title,
+ children,
+ footer,
+ wide,
+ padded = true,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onSubmit?: () => void;
+ title?: string;
+ children: ReactNode;
+ footer?: ReactNode;
+ wide?: boolean;
+ padded?: boolean;
+}) {
+ const isMobile = useIsMobile();
+ const [mounted, setMounted] = useState(open);
+ const [exiting, setExiting] = useState(false);
+ const [dragY, setDragY] = useState(0);
+ const [dragging, setDragging] = useState(false);
+ const panelRef = useRef(null);
+ const dragStart = useRef<{ y: number; t: number } | null>(null);
+
+ // Keep the panel mounted through its exit transition.
+ useEffect(() => {
+ if (open) {
+ setMounted(true);
+ setExiting(false);
+ setDragY(0);
+ return;
+ }
+ if (!mounted) return;
+ setExiting(true);
+ const t = window.setTimeout(() => {
+ setMounted(false);
+ setExiting(false);
+ setDragY(0);
+ }, EXIT_MS);
+ return () => window.clearTimeout(t);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ useScrollLock(mounted);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose, onSubmit]);
+
+ if (!mounted) return null;
+
+ // ── drag-to-dismiss (mobile, from the grabber/header only, so it never
+ // fights the scroll container underneath) ────────────────────────────────
+ const onPointerDown = (e: React.PointerEvent) => {
+ if (!isMobile || e.pointerType === "mouse") return;
+ dragStart.current = { y: e.clientY, t: e.timeStamp };
+ setDragging(true);
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
+ };
+
+ const onPointerMove = (e: React.PointerEvent) => {
+ if (!dragStart.current) return;
+ // Resist upward drags so the sheet feels anchored.
+ const dy = e.clientY - dragStart.current.y;
+ setDragY(dy > 0 ? dy : dy / 4);
+ };
+
+ const endDrag = (e: React.PointerEvent) => {
+ const start = dragStart.current;
+ dragStart.current = null;
+ setDragging(false);
+ if (!start) return;
+ const dy = e.clientY - start.y;
+ const dt = Math.max(1, e.timeStamp - start.t);
+ const velocity = dy / dt; // px/ms
+ const height = panelRef.current?.offsetHeight ?? 400;
+ if (dy > height * 0.28 || velocity > 0.5) onClose();
+ else setDragY(0);
+ };
+
+ const dismissProgress = Math.min(1, Math.max(0, dragY) / 320);
+
+ const panelTransform = exiting
+ ? isMobile
+ ? "translateY(100%)"
+ : "scale(0.96)"
+ : dragY
+ ? `translateY(${dragY}px)`
+ : undefined;
+
+ // Portalled to : any ancestor with a transform (page fade-ins, the
+ // blurred header) would otherwise become the containing block and knock the
+ // fixed overlay out of place.
+ return createPortal(
+
+
+
+
+ {/* Grab area — the whole header is draggable, like iOS sheets. */}
+
+
+
+
+ {title && (
+
+
{title}
+
+
+
+
+ )}
+
+
+ {/* The home-indicator inset is folded into the padding with calc so it
+ adds to the base spacing instead of replacing it. */}
+
+ {children}
+
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/UI/src/components/icons.tsx b/UI/src/components/icons.tsx
new file mode 100644
index 0000000..16cd709
--- /dev/null
+++ b/UI/src/components/icons.tsx
@@ -0,0 +1,155 @@
+import type { SVGProps } from "react";
+
+// 24px stroke icons, sized by the consumer via className.
+
+type Props = SVGProps;
+
+function Base({ children, className = "h-6 w-6", ...props }: Props) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const HomeIcon = (p: Props) => (
+
+
+
+);
+
+export const RequestsIcon = (p: Props) => (
+
+
+
+
+);
+
+export const AgentsIcon = (p: Props) => (
+
+
+
+
+);
+
+export const BellIcon = (p: Props) => (
+
+
+
+
+);
+
+export const SettingsIcon = (p: Props) => (
+
+
+
+
+);
+
+export const ShieldIcon = (p: Props) => (
+
+
+
+
+);
+
+export const LogOutIcon = (p: Props) => (
+
+
+
+
+);
+
+export const ChevronLeftIcon = (p: Props) => (
+
+
+
+);
+
+export const ChevronRightIcon = (p: Props) => (
+
+
+
+);
+
+export const CloseIcon = (p: Props) => (
+
+
+
+);
+
+export const CheckIcon = (p: Props) => (
+
+
+
+);
+
+export const CopyIcon = (p: Props) => (
+
+
+
+
+);
+
+export const FilterIcon = (p: Props) => (
+
+
+
+);
+
+export const MoreIcon = (p: Props) => (
+
+
+
+
+
+);
+
+export const KeyIcon = (p: Props) => (
+
+
+
+
+);
+
+export const PowerIcon = (p: Props) => (
+
+
+
+);
+
+export const TrashIcon = (p: Props) => (
+
+
+
+);
+
+export const PencilIcon = (p: Props) => (
+
+
+
+);
+
+export const PlugIcon = (p: Props) => (
+
+
+
+);
+
+export const WrapIcon = (p: Props) => (
+
+
+
+
+
+);
diff --git a/UI/src/components/ui.tsx b/UI/src/components/ui.tsx
index af01772..fadac62 100644
--- a/UI/src/components/ui.tsx
+++ b/UI/src/components/ui.tsx
@@ -21,7 +21,9 @@ export function Button({
{loading && }
{children}
@@ -54,68 +56,107 @@ export function Card({
);
}
-export function Input({ label, hint, className = "", ...props }: InputHTMLAttributes & { label?: string; hint?: string }) {
+export function Input({
+ label,
+ hint,
+ className = "",
+ ...props
+}: InputHTMLAttributes & { label?: string; hint?: string }) {
return (
- {label && {label} }
+ {label && {label} }
- {hint && {hint} }
+ {hint && {hint} }
);
}
-export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes & { label?: string }) {
+export function Textarea({
+ label,
+ className = "",
+ ...props
+}: TextareaHTMLAttributes & { label?: string }) {
return (
- {label && {label} }
+ {label && {label} }
);
}
-export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
+export function Toggle({
+ checked,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ label?: string;
+}) {
return (
onChange(!checked)}
- className="inline-flex items-center gap-3"
+ // Padded out to a full-height touch target without moving the track.
+ className="-my-2 inline-flex min-h-11 items-center gap-3 py-2"
>
- {label && {label} }
+ {label && {label} }
);
}
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
return (
-
+
{icon &&
{icon}
}
-
{title}
+
{title}
{subtitle &&
{subtitle}
}
);
}
-export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
+export function PageHeader({
+ title,
+ subtitle,
+ actions,
+}: {
+ title: string;
+ subtitle?: string;
+ actions?: ReactNode;
+}) {
return (
-
-
{title}
+
+
{title}
{subtitle &&
{subtitle}
}
- {actions &&
{actions}
}
+ {/* Actions span the width on phones so they stay thumb-sized. */}
+ {actions && (
+
{actions}
+ )}
);
}
+
+/** Shared classes for inline "see more" links — pads them to a real touch target. */
+export const textLinkClass =
+ "-my-2 inline-flex min-h-11 items-center py-2 text-sm text-primary hover:underline sm:min-h-0 sm:py-0";
diff --git a/UI/src/hooks/useMediaQuery.ts b/UI/src/hooks/useMediaQuery.ts
new file mode 100644
index 0000000..3183387
--- /dev/null
+++ b/UI/src/hooks/useMediaQuery.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from "react";
+
+export function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(() =>
+ typeof window === "undefined" ? false : window.matchMedia(query).matches,
+ );
+
+ useEffect(() => {
+ const mql = window.matchMedia(query);
+ const onChange = () => setMatches(mql.matches);
+ onChange();
+ mql.addEventListener("change", onChange);
+ return () => mql.removeEventListener("change", onChange);
+ }, [query]);
+
+ return matches;
+}
+
+/** Matches Tailwind's `sm` breakpoint — below it we switch to sheets + tab bar. */
+export function useIsMobile(): boolean {
+ return useMediaQuery("(max-width: 639px)");
+}
+
+/** True on touch-primary devices, where hover affordances don't exist. */
+export function useIsTouch(): boolean {
+ return useMediaQuery("(pointer: coarse)");
+}
diff --git a/UI/src/hooks/useScrollLock.ts b/UI/src/hooks/useScrollLock.ts
new file mode 100644
index 0000000..aaf78cf
--- /dev/null
+++ b/UI/src/hooks/useScrollLock.ts
@@ -0,0 +1,44 @@
+import { useEffect } from "react";
+
+// `overflow: hidden` on is not enough on iOS Safari — the page still
+// rubber-bands behind the overlay. Pinning the body and restoring scroll on
+// release is the only approach that holds across engines.
+
+let locks = 0;
+let savedScrollY = 0;
+let saved: Partial
= {};
+
+function lock() {
+ if (locks++ > 0) return;
+ const body = document.body;
+ savedScrollY = window.scrollY;
+ saved = {
+ position: body.style.position,
+ top: body.style.top,
+ left: body.style.left,
+ right: body.style.right,
+ width: body.style.width,
+ overflow: body.style.overflow,
+ };
+ body.style.position = "fixed";
+ body.style.top = `-${savedScrollY}px`;
+ body.style.left = "0";
+ body.style.right = "0";
+ body.style.width = "100%";
+ body.style.overflow = "hidden";
+}
+
+function unlock() {
+ if (--locks > 0) return;
+ locks = 0;
+ Object.assign(document.body.style, saved);
+ window.scrollTo(0, savedScrollY);
+}
+
+export function useScrollLock(active: boolean) {
+ useEffect(() => {
+ if (!active) return;
+ lock();
+ return unlock;
+ }, [active]);
+}
diff --git a/UI/src/index.css b/UI/src/index.css
index 4bc0beb..1b38b9e 100644
--- a/UI/src/index.css
+++ b/UI/src/index.css
@@ -2,6 +2,9 @@
/* PatchPass design tokens (Tailwind v4 @theme) */
@theme {
+ /* Small-phone breakpoint (iPhone SE and friends sit below it). */
+ --breakpoint-xs: 24rem;
+
--color-bg: #0a0e17;
--color-surface: #111726;
--color-surface-raised: #1a2234;
@@ -24,6 +27,15 @@
--color-cancelled: #7a8296;
}
+/* Layout metrics shared between the shell and the pages that dodge it. */
+:root {
+ --bottom-nav-h: 3.75rem;
+ --safe-top: env(safe-area-inset-top, 0px);
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-left: env(safe-area-inset-left, 0px);
+ --safe-right: env(safe-area-inset-right, 0px);
+}
+
html,
body,
#root {
@@ -35,21 +47,158 @@ body,
overflow-x: clip;
}
-body {
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
- sans-serif;
- -webkit-font-smoothing: antialiased;
+/* Landscape notch insets, applied once for all in-flow content. Fixed-position
+ chrome escapes this and re-applies the insets itself via `px-safe`. */
+#root {
+ padding-left: var(--safe-left);
+ padding-right: var(--safe-right);
}
-button:hover,
-a:hover {
- cursor: pointer;
+html {
+ /* Stop iOS from re-flowing type when the device rotates. */
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui, "Segoe UI", Roboto,
+ Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ /* No rubber-band / pull-to-refresh: the single biggest "this is a web page" tell. */
+ overscroll-behavior-y: none;
+}
+
+/* Kill the grey flash on tap; every interactive element supplies its own
+ :active feedback instead (see .tap / Button). */
+* {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button,
+a,
+[role="button"],
+label,
+summary {
+ -webkit-touch-callout: none;
+ /* Removes the 300ms click delay without disabling pinch-zoom on the page. */
+ touch-action: manipulation;
+}
+
+/* App chrome shouldn't be text-selectable — content still is. */
+button,
+nav,
+[data-chrome] {
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* Hover styles are already gated to hover-capable devices by Tailwind v4, but
+ cursor rules are not. */
+@media (hover: hover) {
+ button:hover,
+ a:hover {
+ cursor: pointer;
+ }
}
* {
scrollbar-color: var(--color-border-strong) transparent;
}
+/* Touch devices get no visible scrollbar gutters. */
+@media (pointer: coarse) {
+ * {
+ scrollbar-width: none;
+ }
+ *::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+}
+
+/* iOS zooms the viewport when a focused input's font-size is under 16px. */
+@media (pointer: coarse) {
+ input,
+ select,
+ textarea {
+ font-size: 16px;
+ }
+}
+
+input,
+textarea,
+select {
+ /* Keep the caret and selection on-brand across engines. */
+ caret-color: var(--color-primary);
+}
+
+/* Give sliders a full-height hit area (the native thumb stays centred) and let
+ vertical drags still scroll the page. */
+input[type="range"] {
+ height: 2.75rem;
+ touch-action: pan-y;
+}
+
+::selection {
+ background-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
+}
+
+/* ── Utilities ──────────────────────────────────────────────────────────────── */
+
+@utility pt-safe {
+ padding-top: var(--safe-top);
+}
+@utility pb-safe {
+ padding-bottom: var(--safe-bottom);
+}
+@utility px-safe {
+ padding-left: var(--safe-left);
+ padding-right: var(--safe-right);
+}
+@utility mb-safe {
+ margin-bottom: var(--safe-bottom);
+}
+
+/* Native-feeling press feedback for cards and rows. */
+@utility tap {
+ transition:
+ transform 0.12s ease,
+ background-color 0.12s ease,
+ opacity 0.12s ease;
+
+ &:active {
+ transform: scale(0.985);
+ }
+}
+
+@utility tap-sm {
+ transition:
+ transform 0.1s ease,
+ background-color 0.1s ease,
+ opacity 0.1s ease;
+
+ &:active {
+ transform: scale(0.94);
+ opacity: 0.75;
+ }
+}
+
+/* Horizontal scrollers (diffs, code, filter chips) must not hand the gesture
+ back to the browser's back-swipe. */
+@utility scroll-x {
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+}
+
+@utility no-scrollbar {
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
/* Diff syntax coloring */
.diff-add {
background-color: rgba(62, 207, 142, 0.13);
@@ -66,6 +215,8 @@ a:hover {
color: var(--color-faint);
}
+/* ── Motion ─────────────────────────────────────────────────────────────────── */
+
@keyframes fadeIn {
from {
opacity: 0;
@@ -94,3 +245,86 @@ a:hover {
.animate-pulse-ring {
animation: pulseRing 1.8s ease-out infinite;
}
+
+/* Sheet + backdrop transitions, tuned to iOS's ease-out curve. */
+@keyframes sheetUp {
+ from {
+ transform: translateY(100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+.animate-sheet-up {
+ animation: sheetUp 0.28s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+@keyframes scaleIn {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+.animate-scale-in {
+ animation: scaleIn 0.16s ease-out;
+}
+
+@keyframes backdropIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+.animate-backdrop-in {
+ animation: backdropIn 0.22s ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+ .tap:active,
+ .tap-sm:active {
+ transform: none;
+ }
+}
+
+/* ── react-toastify, restyled to the design tokens and lifted above the tabs ── */
+
+.Toastify__toast-container {
+ padding: 0;
+ width: auto;
+}
+
+.Toastify__toast {
+ border-radius: 0.75rem;
+ border: 1px solid var(--color-border-strong);
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ font-family: inherit;
+ font-size: 0.875rem;
+ min-height: 3rem;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
+}
+
+@media (max-width: 640px) {
+ .Toastify__toast-container {
+ left: 0.75rem;
+ right: 0.75rem;
+ width: auto;
+ bottom: calc(var(--bottom-nav-h) + var(--safe-bottom) + 0.75rem);
+ }
+ .Toastify__toast {
+ margin-bottom: 0.5rem;
+ }
+}
diff --git a/UI/src/main.tsx b/UI/src/main.tsx
index 657ea6a..a793493 100644
--- a/UI/src/main.tsx
+++ b/UI/src/main.tsx
@@ -23,7 +23,7 @@ import { PrivacyPage, TermsPage } from "./pages/Legal";
function FullScreenLoader() {
return (
-
+
);
@@ -66,7 +66,19 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
+
,
diff --git a/UI/src/pages/Admin.tsx b/UI/src/pages/Admin.tsx
index ffec633..ebedd91 100644
--- a/UI/src/pages/Admin.tsx
+++ b/UI/src/pages/Admin.tsx
@@ -4,6 +4,8 @@ import { admin } from "../api/client";
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
+import { ConfirmSheet } from "../components/ConfirmSheet";
+import { ScrollRow } from "../components/ScrollRow";
import { relativeTime } from "../utils";
type Tab = "users" | "agents" | "settings" | "audit";
@@ -20,19 +22,19 @@ export function AdminPage() {
return (
-
+
{tabs.map((t) => (
setTab(t.id)}
- className={`shrink-0 px-4 py-2 text-sm font-medium transition ${
+ className={`min-h-11 shrink-0 px-4 text-sm font-medium transition ${
tab === t.id ? "border-b-2 border-accent text-text" : "text-muted hover:text-text"
}`}
>
{t.label}
))}
-
+
{tab === "users" &&
}
{tab === "agents" &&
}
{tab === "settings" &&
}
@@ -45,6 +47,8 @@ function UsersTab() {
const { user: me } = useAuth();
const [users, setUsers] = useState
([]);
const [loading, setLoading] = useState(true);
+ const [pendingDelete, setPendingDelete] = useState(null);
+ const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
@@ -75,13 +79,16 @@ function UsersTab() {
}
};
const remove = async (u: AdminUser) => {
- if (!confirm(`Delete user "${u.username}" and all their data?`)) return;
+ setBusy(true);
try {
await admin.deleteUser(u.id);
toast.success("User deleted");
+ setPendingDelete(null);
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
+ } finally {
+ setBusy(false);
}
};
@@ -89,41 +96,54 @@ function UsersTab() {
return (
{users.map((u) => (
-
-
-
-
{u.display_name}
-
@{u.username}
- {u.role === "ADMIN" && (
-
- ADMIN
-
- )}
- {u.disabled && (
-
- DISABLED
-
- )}
+
+
+
+
+ {u.display_name}
+ @{u.username}
+ {u.role === "ADMIN" && (
+
+ ADMIN
+
+ )}
+ {u.disabled && (
+
+ DISABLED
+
+ )}
+
+
+ {u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
+
-
- {u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
-
+ {u.id !== me?.id && (
+
+ setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
+ {u.role === "ADMIN" ? "Demote" : "Promote"}
+
+ toggleDisabled(u)}>
+ {u.disabled ? "Enable" : "Disable"}
+
+ setPendingDelete(u)}>
+ Delete
+
+
+ )}
- {u.id !== me?.id && (
-
- setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
- {u.role === "ADMIN" ? "Demote" : "Promote"}
-
- toggleDisabled(u)}>
- {u.disabled ? "Enable" : "Disable"}
-
- remove(u)}>
- Delete
-
-
- )}
))}
+
+
setPendingDelete(null)}
+ onConfirm={() => pendingDelete && remove(pendingDelete)}
+ loading={busy}
+ danger
+ title="Delete user"
+ message={`Delete "${pendingDelete?.username}" and all of their agents, change requests and notifications? This cannot be undone.`}
+ confirmLabel="Delete"
+ />
);
}
@@ -131,6 +151,9 @@ function UsersTab() {
function AgentsTab() {
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
const [loading, setLoading] = useState(true);
+ const [pendingDelete, setPendingDelete] = useState
(null);
+ const [busy, setBusy] = useState(false);
+
const load = useCallback(async () => {
try {
setAgents(await admin.agents());
@@ -151,13 +174,16 @@ function AgentsTab() {
}
};
const remove = async (a: Agent) => {
- if (!confirm(`Delete agent "${a.name}"?`)) return;
+ setBusy(true);
try {
await admin.deleteAgent(a.id);
toast.success("Agent deleted");
+ setPendingDelete(null);
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
+ } finally {
+ setBusy(false);
}
};
@@ -165,29 +191,42 @@ function AgentsTab() {
return (
{agents.map((a) => (
-
-
-
-
{a.name}
-
by @{a.owner.username}
- {a.disabled && (
-
- DISABLED
-
- )}
+
+
+
+
+ {a.name}
+ by @{a.owner.username}
+ {a.disabled && (
+
+ DISABLED
+
+ )}
+
+ {a.description &&
{a.description}
}
+
+
+ toggle(a)}>
+ {a.disabled ? "Enable" : "Disable"}
+
+ setPendingDelete(a)}>
+ Delete
+
- {a.description &&
{a.description}
}
-
-
- toggle(a)}>
- {a.disabled ? "Enable" : "Disable"}
-
- remove(a)}>
- Delete
-
))}
+
+
setPendingDelete(null)}
+ onConfirm={() => pendingDelete && remove(pendingDelete)}
+ loading={busy}
+ danger
+ title="Delete agent"
+ message={`Delete "${pendingDelete?.name}" and all of its change requests? This cannot be undone.`}
+ confirmLabel="Delete"
+ />
);
}
@@ -195,7 +234,10 @@ function AgentsTab() {
function SettingsTab() {
const [settings, setSettings] = useState
(null);
useEffect(() => {
- admin.settings().then(setSettings).catch(() => {});
+ admin
+ .settings()
+ .then(setSettings)
+ .catch(() => {});
}, []);
const update = async (patch: Partial) => {
@@ -211,15 +253,15 @@ function SettingsTab() {
if (!settings) return ;
return (
-
-
+
+
Enable registration
Allow new humans to create accounts.
update({ registration_enabled: v })} />
-
-
+
+
Enable requests
Allow agents to submit new change requests platform-wide.
@@ -251,14 +293,20 @@ function AuditTab() {
{logs.map((l) => (
-
- {l.action}
-
- {l.actor ? `@${l.actor.username}` : "system"}
- {l.target_type && ` → ${l.target_type}:${l.target_id}`}
- {l.detail && ` · ${l.detail}`}
-
- {relativeTime(l.created_at)}
+
+
+
+ {l.action}
+
+
+ {l.actor ? `@${l.actor.username}` : "system"}
+ {l.target_type && ` → ${l.target_type}:${l.target_id}`}
+ {l.detail && ` · ${l.detail}`}
+
+
+ {relativeTime(l.created_at)}
+
+
))}
@@ -267,7 +315,7 @@ function AuditTab() {
setPage((p) => p - 1)}>
Previous
-
+
Page {page} of {totalPages}
= totalPages} onClick={() => setPage((p) => p + 1)}>
diff --git a/UI/src/pages/Agents.tsx b/UI/src/pages/Agents.tsx
index 0716b88..00d75d0 100644
--- a/UI/src/pages/Agents.tsx
+++ b/UI/src/pages/Agents.tsx
@@ -5,6 +5,11 @@ import type { Agent } from "../api/types";
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
import { AgentAvatar } from "../components/AgentAvatar";
import { AgentFormModal, ConnectModal } from "../components/AgentModals";
+import { ConfirmSheet } from "../components/ConfirmSheet";
+import { Sheet } from "../components/Sheet";
+import { KeyIcon, MoreIcon, PencilIcon, PowerIcon, TrashIcon } from "../components/icons";
+
+type Confirm = { agent: Agent; kind: "regenerate" | "delete" };
export function AgentsPage() {
const [agents, setAgents] = useState([]);
@@ -13,6 +18,9 @@ export function AgentsPage() {
const [editing, setEditing] = useState(null);
const [connectAgent, setConnectAgent] = useState(null);
const [revealedKey, setRevealedKey] = useState(null);
+ const [menuAgent, setMenuAgent] = useState(null);
+ const [confirm, setConfirm] = useState(null);
+ const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
@@ -35,15 +43,18 @@ export function AgentsPage() {
};
const regenerate = async (agent: Agent) => {
- if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return;
+ setBusy(true);
try {
const updated = await agentsApi.regenerateKey(agent.id);
toast.success("API key regenerated");
setRevealedKey(updated.api_key ?? null);
setConnectAgent(updated);
+ setConfirm(null);
load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
+ } finally {
+ setBusy(false);
}
};
@@ -58,16 +69,24 @@ export function AgentsPage() {
};
const remove = async (agent: Agent) => {
- if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return;
+ setBusy(true);
try {
await agentsApi.remove(agent.id);
toast.success("Agent deleted");
+ setConfirm(null);
load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
+ } finally {
+ setBusy(false);
}
};
+ const openEdit = (a: Agent) => {
+ setEditing(a);
+ setFormOpen(true);
+ };
+
return (
) : (
-
+
{agents.map((a) => (
-
+
{a.name}
{a.disabled && (
-
+
DISABLED
)}
{a.description &&
{a.description}
}
-
+
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
{a.api_key_masked}
-
-
setConnectAgent(a)}>
+
+ {/* Phones: two primary actions plus an overflow sheet, rather
+ than five wrapped ghost buttons. */}
+
+ setConnectAgent(a)}>
Connect
+ openEdit(a)}>
+ Edit
+
{
- setEditing(a);
- setFormOpen(true);
- }}
+ className="w-11 shrink-0 px-0"
+ aria-label={`More actions for ${a.name}`}
+ onClick={() => setMenuAgent(a)}
>
+
+
+
+
+
+ setConnectAgent(a)}>
+ Connect
+
+ openEdit(a)}>
Edit
- regenerate(a)}>
+ setConfirm({ agent: a, kind: "regenerate" })}>
Regenerate key
toggleDisabled(a)}>
{a.disabled ? "Enable" : "Disable"}
- remove(a)}>
+ setConfirm({ agent: a, kind: "delete" })}
+ >
Delete
@@ -146,23 +183,106 @@ export function AgentsPage() {
)}
- setFormOpen(false)}
- agent={editing}
- onSaved={onSaved}
+ setFormOpen(false)} agent={editing} onSaved={onSaved} />
+
+ {
+ setConnectAgent(null);
+ setRevealedKey(null);
+ }}
+ agent={connectAgent}
+ revealedKey={revealedKey}
/>
- {connectAgent && (
- {
- setConnectAgent(null);
- setRevealedKey(null);
+
+ {/* Overflow actions (mobile) */}
+ setMenuAgent(null)}
+ title={menuAgent?.name ?? ""}
+ padded={false}
+ >
+ }
+ label="Edit agent"
+ onClick={() => {
+ const a = menuAgent!;
+ setMenuAgent(null);
+ openEdit(a);
}}
- agent={connectAgent}
- revealedKey={revealedKey}
/>
- )}
+ }
+ label="Regenerate key"
+ onClick={() => {
+ const a = menuAgent!;
+ setMenuAgent(null);
+ setConfirm({ agent: a, kind: "regenerate" });
+ }}
+ />
+ }
+ label={menuAgent?.disabled ? "Enable agent" : "Disable agent"}
+ onClick={() => {
+ const a = menuAgent!;
+ setMenuAgent(null);
+ toggleDisabled(a);
+ }}
+ />
+ }
+ label="Delete agent"
+ danger
+ onClick={() => {
+ const a = menuAgent!;
+ setMenuAgent(null);
+ setConfirm({ agent: a, kind: "delete" });
+ }}
+ />
+
+
+ setConfirm(null)}
+ loading={busy}
+ danger
+ title={confirm?.kind === "delete" ? "Delete agent" : "Regenerate API key"}
+ message={
+ confirm?.kind === "delete"
+ ? `Delete "${confirm.agent.name}"? All of its change requests will be permanently deleted.`
+ : `Regenerate the API key for "${confirm?.agent.name}"? The old key stops working immediately.`
+ }
+ confirmLabel={confirm?.kind === "delete" ? "Delete" : "Regenerate"}
+ onConfirm={() => {
+ if (!confirm) return;
+ if (confirm.kind === "delete") remove(confirm.agent);
+ else regenerate(confirm.agent);
+ }}
+ />
);
}
+
+function ActionRow({
+ icon,
+ label,
+ onClick,
+ danger,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ danger?: boolean;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/UI/src/pages/Dashboard.tsx b/UI/src/pages/Dashboard.tsx
index 6109b89..9694620 100644
--- a/UI/src/pages/Dashboard.tsx
+++ b/UI/src/pages/Dashboard.tsx
@@ -3,9 +3,10 @@ import { Link } from "react-router-dom";
import { requests as requestsApi, agents as agentsApi } from "../api/client";
import type { Agent, ChangeRequest, RequestState } from "../api/types";
import { useNotifications } from "../context/NotificationsContext";
-import { Card, EmptyState, PageHeader, Spinner } from "../components/ui";
+import { Card, EmptyState, PageHeader, Spinner, textLinkClass } from "../components/ui";
import { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar";
+import { ChevronRightIcon } from "../components/icons";
import { expiresIn, relativeTime } from "../utils";
const summaryTiles: { state: RequestState; label: string }[] = [
@@ -56,20 +57,25 @@ export function DashboardPage() {
subtitle="Requests awaiting your review, and your connected agents."
/>
-
+
{summaryTiles.map((t) => (
-
- {counts[t.state] ?? 0}
- {t.label}
+
+
+ {counts[t.state] ?? 0}
+
+ {t.label}
))}
-
-
+ {/* min-w-0 on both tracks: grid items default to min-width:auto, so the
+ truncated meta lines below would otherwise widen the column past the
+ viewport on narrow phones. */}
+
+
Awaiting review
-
+
View all →
@@ -80,7 +86,7 @@ export function DashboardPage() {
subtitle="No requests are waiting for your review right now."
/>
) : (
-
+
{pending.map((r) => (
))}
@@ -88,15 +94,19 @@ export function DashboardPage() {
)}
-
+
Agents
-
+
Manage →
{agents.length === 0 ? (
-
+
) : (
{agents.map((a) => (
@@ -123,30 +133,38 @@ function PendingRow({ request }: { request: ChangeRequest }) {
const exp = expiresIn(request.expires_at);
return (
-
-
-
-
-
-
-
{request.title}
- {request.resubmitted && (
-
- UPDATED
-
- )}
-
-
- {request.agent?.name} · {request.changes.length} change
- {request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
-
-
+
+
+
+
+
+
{request.title}
+ {request.resubmitted && (
+
+ UPDATED
+
+ )}
-
+
+ {request.agent?.name} · {request.changes.length} change
+ {request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
+
+ {/* Stacked under the title on phones, where a side column would
+ squeeze the text to a couple of words per line. */}
+
- {exp.text}
+
+ {exp.text}
+
+
+
+
+ {exp.text}
+
+
+
);
diff --git a/UI/src/pages/Legal.tsx b/UI/src/pages/Legal.tsx
index 4065b01..9f606b2 100644
--- a/UI/src/pages/Legal.tsx
+++ b/UI/src/pages/Legal.tsx
@@ -2,23 +2,28 @@ import { Link } from "react-router-dom";
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
-
-
- ← Back
-
-
{title}
-
- {children}
+
+
+
+ ← Back
+
+
{title}
+
+ {children}
+
+
+
+ Privacy Policy
+
+ ·
+
+ Terms of Service
+
+
-
-
- Privacy Policy
-
- ·
-
- Terms of Service
-
-
);
}
@@ -27,28 +32,28 @@ export function PrivacyPage() {
return (
- PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data
- the platform stores and the controls you have over it.
+ PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data the
+ platform stores and the controls you have over it.
Data we store
We store the account data you provide (username, display name, a bcrypt-hashed password, and an
optional TOTP secret if you enable two-factor authentication), the agents you create (name,
- description, website, icon URL, and an API key), the change requests your agents submit, the
- decisions you make, and your in-app notifications.
+ description, website, icon URL, and an API key), the change requests your agents submit, the decisions
+ you make, and your in-app notifications.
Agent icons
- When you set an agent icon URL, the backend fetches it once to verify it points to a valid image
- (JPG, PNG, or GIF, under 1 MB). The image itself is not stored or cached —
- only the URL you provided is kept.
+ When you set an agent icon URL, the backend fetches it once to verify it points to a valid image (JPG,
+ PNG, or GIF, under 1 MB). The image itself is not stored or cached — only the
+ URL you provided is kept.
How your data is used
Data is used solely to operate the approval workflow: routing agent requests to you for review,
- recording decisions, and delivering notifications. We do not sell data or share it with third
- parties. Administrators of your instance can view platform data for moderation; access to another
- user's request payloads is explicitly audited.
+ recording decisions, and delivering notifications. We do not sell data or share it with third parties.
+ Administrators of your instance can view platform data for moderation; access to another user's
+ request payloads is explicitly audited.
Retention
@@ -58,8 +63,8 @@ export function PrivacyPage() {
Your rights (GDPR)
You can export all of your data as machine-readable JSON at any time from Settings. You can also
- delete your account, which permanently removes your account and cascades to all your agents,
- change requests, and notifications. These actions are self-service and take effect immediately.
+ delete your account, which permanently removes your account and cascades to all your agents, change
+ requests, and notifications. These actions are self-service and take effect immediately.
Security
@@ -92,13 +97,13 @@ export function TermsPage() {
Rate limits
- To keep the platform usable, agents are limited to 15 requests per hour and a configurable number
- of simultaneous pending requests, and each human may own up to 5 agents.
+ To keep the platform usable, agents are limited to 15 requests per hour and a configurable number of
+ simultaneous pending requests, and each human may own up to 5 agents.
Availability
- This is self-hosted software. Availability, backups, and data durability are the responsibility of
- the operator of this instance.
+ This is self-hosted software. Availability, backups, and data durability are the responsibility of the
+ operator of this instance.
Changes
These terms may be updated by the operator of your instance.
diff --git a/UI/src/pages/Login.tsx b/UI/src/pages/Login.tsx
index 8061123..d11c3ff 100644
--- a/UI/src/pages/Login.tsx
+++ b/UI/src/pages/Login.tsx
@@ -44,7 +44,10 @@ export function LoginPage() {
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
- autoFocus
+ autoComplete="username"
+ autoCapitalize="none"
+ autoCorrect="off"
+ spellCheck={false}
required
/>
setPassword(e.target.value)}
+ autoComplete="current-password"
required
/>
{needsTotp && (
@@ -61,6 +65,8 @@ export function LoginPage() {
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
inputMode="numeric"
+ autoComplete="one-time-code"
+ pattern="[0-9]*"
autoFocus
/>
)}
@@ -90,10 +96,10 @@ export function AuthShell({
children: React.ReactNode;
}) {
return (
-
-
+
+
-
✅
+
PatchPass
diff --git a/UI/src/pages/Notifications.tsx b/UI/src/pages/Notifications.tsx
index f506d1a..6940007 100644
--- a/UI/src/pages/Notifications.tsx
+++ b/UI/src/pages/Notifications.tsx
@@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext";
import { Button, Card, EmptyState, PageHeader } from "../components/ui";
+import { ChevronRightIcon } from "../components/icons";
import { relativeTime } from "../utils";
export function NotificationsPage() {
@@ -33,7 +34,7 @@ export function NotificationsPage() {
{items.map((n) => (
{
@@ -41,12 +42,20 @@ export function NotificationsPage() {
if (n.request_id) navigate(`/requests/${n.request_id}`);
}}
>
- {!n.read && }
+
-
{n.title}
-
{n.message}
+
{n.title}
+
{n.message}
+
{relativeTime(n.created_at)}
- {relativeTime(n.created_at)}
+
+ {relativeTime(n.created_at)}
+
+ {n.request_id && (
+
+ )}
))}
diff --git a/UI/src/pages/Register.tsx b/UI/src/pages/Register.tsx
index 17acab7..15bd648 100644
--- a/UI/src/pages/Register.tsx
+++ b/UI/src/pages/Register.tsx
@@ -55,19 +55,24 @@ export function RegisterPage() {
value={username}
onChange={(e) => setUsername(e.target.value)}
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
- autoFocus
+ autoComplete="username"
+ autoCapitalize="none"
+ autoCorrect="off"
+ spellCheck={false}
required
/>
setDisplayName(e.target.value)}
+ autoComplete="name"
/>
setPassword(e.target.value)}
+ autoComplete="new-password"
required
/>
setConfirm(e.target.value)}
+ autoComplete="new-password"
required
/>
diff --git a/UI/src/pages/RequestDetail.tsx b/UI/src/pages/RequestDetail.tsx
index 46237a5..6b6e53d 100644
--- a/UI/src/pages/RequestDetail.tsx
+++ b/UI/src/pages/RequestDetail.tsx
@@ -8,6 +8,7 @@ import { StateBadge } from "../components/StateBadge";
import { ChangeList } from "../components/ChangeRenderer";
import { AgentAvatar } from "../components/AgentAvatar";
import { DecisionModal } from "../components/DecisionModal";
+import { ChevronLeftIcon } from "../components/icons";
import { expiresIn, formatDateTime, relativeTime } from "../utils";
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
@@ -58,146 +59,195 @@ export function RequestDetailPage() {
const canDecide = request.state === "PENDING";
return (
-
-
- ← Back to requests
-
+
+ {/* The fade-in wrapper is kept inside: its transform would otherwise make
+ it the containing block for the fixed decision bar below. */}
+
+
+
+ Requests
+
- {request.resubmitted && request.state === "PENDING" && (
-
-
- UPDATED
-
-
- This request was revised by the agent after you requested changes (update #{request.update_count}).
- Please re-review the changes below.
-
-
- )}
+ {request.resubmitted && request.state === "PENDING" && (
+
+
+ UPDATED
+
+
+ This request was revised by the agent after you requested changes (update #
+ {request.update_count}). Please re-review the changes below.
+
+
+ )}
-
-
-
-
-
-
{request.title}
-
+
+
+
+
+
+ {request.title}
+
+
- {request.description &&
{request.description}
}
+ {request.description && (
+
+ {request.description}
+
+ )}
+
+ {request.comment && (
+
+
+ {request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
+
+
{request.comment}
+
+ )}
+
+
+ Proposed changes ({request.changes.length})
+
+
- {request.comment && (
-
-
- {request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
-
-
{request.comment}
-
- )}
-
-
- Proposed changes ({request.changes.length})
-
-
-
-
-
- {canDecide && (
-
- Your decision
- setDecision("APPROVE")}>
- Approve
-
- setDecision("REQUEST_CHANGES")}>
- Request changes
-
- setDecision("REJECT")}>
- Reject
-
-
- {exp.text}
-
-
- )}
-
-
- Agent
- {request.agent ? (
-
- ) : (
- Unknown
+
+ {/* On phones this lives in the sticky bar at the bottom instead. */}
+ {canDecide && (
+
+ Your decision
+ setDecision("APPROVE")}>
+ Approve
+
+ setDecision("REQUEST_CHANGES")}>
+ Request changes
+
+ setDecision("REJECT")}>
+ Reject
+
+
+ {exp.text}
+
+
)}
- {request.agent?.description && (
-
{request.agent.description}
- )}
-
-
- {request.request_id}} />
- {request.content_hash}}
- />
-
-
- {request.decided_at &&
}
- {request.consumed_at &&
}
- {request.update_count > 0 &&
}
-
-
- {request.metadata && Object.keys(request.metadata).length > 0 && (
- Metadata
-
- {Object.entries(request.metadata).map(([k, v]) => (
- {String(v)}} />
- ))}
-
+ Agent
+ {request.agent ? (
+
+ ) : (
+ Unknown
+ )}
+ {request.agent?.description && (
+ {request.agent.description}
+ )}
- )}
- {request.receipt && (
-
-
- 🔏 Signed receipt
-
-
-
-
- {request.receipt.signature}}
- />
-
+
+ {request.request_id}} />
+ {request.content_hash}}
+ />
+
+
+ {request.decided_at &&
}
+ {request.consumed_at &&
}
+ {request.update_count > 0 &&
}
- )}
+
+ {request.metadata && Object.keys(request.metadata).length > 0 && (
+
+ Metadata
+
+ {Object.entries(request.metadata).map(([k, v]) => (
+ {String(v)}} />
+ ))}
+
+
+ )}
+
+ {request.receipt && (
+
+
+ 🔏 Signed receipt
+
+
+
+
+ {request.receipt.signature}
+ }
+ />
+
+
+ )}
+
-
setDecision(null)} onDone={setRequest} />
+ {canDecide && (
+ <>
+ {/* Reserve room so the last card is never trapped under the bar. */}
+
+
+
+
+
+ {exp.text}
+
+
+ setDecision("REJECT")}>
+ Reject
+
+ setDecision("REQUEST_CHANGES")}>
+ Changes
+
+ setDecision("APPROVE")}>
+ Approve
+
+
+
+
+ >
+ )}
+
+ setDecision(null)}
+ onDone={setRequest}
+ />
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
-
+
{label}
{value}
diff --git a/UI/src/pages/Requests.tsx b/UI/src/pages/Requests.tsx
index 3e4e509..18c3f38 100644
--- a/UI/src/pages/Requests.tsx
+++ b/UI/src/pages/Requests.tsx
@@ -6,6 +6,8 @@ import { useNotifications } from "../context/NotificationsContext";
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
import { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar";
+import { ChevronRightIcon } from "../components/icons";
+import { ScrollRow } from "../components/ScrollRow";
import { relativeTime } from "../utils";
const STATES: RequestState[] = [
@@ -31,7 +33,10 @@ export function RequestsPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
- agentsApi.list().then(setAgents).catch(() => {});
+ agentsApi
+ .list()
+ .then(setAgents)
+ .catch(() => {});
}, []);
const load = useCallback(async () => {
@@ -56,54 +61,52 @@ export function RequestsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ const chip = (active: boolean) =>
+ `tap-sm min-h-9 shrink-0 rounded-full border px-3.5 text-sm font-medium transition ${
+ active
+ ? "border-primary/40 bg-primary/15 text-primary"
+ : "border-border-strong bg-surface text-muted hover:text-text"
+ }`;
+
return (
-
-
{
- setStateFilter(e.target.value as RequestState | "");
- setPage(1);
- }}
- className="w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none sm:hidden"
- >
- All states
- {STATES.map((s) => (
- {s.replace("_", " ").toLowerCase()}
- ))}
-
-
-
{
- setStateFilter("");
- setPage(1);
- }}
- className={`shrink-0 rounded-lg px-3 py-2 text-sm ${stateFilter === "" ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
- >
- All
-
- {STATES.map((s) => (
+
+ {/* No scroll-snap: it clamps the resting scrollLeft to the container's
+ padding, which eats the left gutter, and flick-snapping through
+ short chips feels wrong anyway. */}
+
{
- setStateFilter(s);
+ setStateFilter("");
setPage(1);
}}
- className={`shrink-0 rounded-lg px-3 py-2 text-sm ${stateFilter === s ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
+ className={chip(stateFilter === "")}
>
- {s.replace("_", " ").toLowerCase()}
+ All
- ))}
-
+ {STATES.map((s) => (
+
{
+ setStateFilter(s);
+ setPage(1);
+ }}
+ className={chip(stateFilter === s)}
+ >
+ {s.replace("_", " ").toLowerCase()}
+
+ ))}
+
+
{
setAgentFilter(e.target.value ? Number(e.target.value) : "");
setPage(1);
}}
- className="w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none sm:ml-auto sm:w-auto"
+ className="min-h-11 w-full rounded-lg border border-border-strong bg-bg px-3 text-text outline-none sm:min-h-10 sm:w-auto"
>
All agents
{agents.map((a) => (
@@ -124,25 +127,33 @@ export function RequestsPage() {
{items.map((r) => (
-
-
+
+
-
-
{r.title}
+
+
+ {r.title}
+
{r.resubmitted && r.state === "PENDING" && (
UPDATED
)}
-
+
{r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
{relativeTime(r.created_at)}
+
+
+
+
+
+
-
+
))}
@@ -154,7 +165,7 @@ export function RequestsPage() {
setPage((p) => p - 1)}>
Previous
-
+
Page {page} of {totalPages}
= totalPages} onClick={() => setPage((p) => p + 1)}>
diff --git a/UI/src/pages/Settings.tsx b/UI/src/pages/Settings.tsx
index e2cf2b4..73aa530 100644
--- a/UI/src/pages/Settings.tsx
+++ b/UI/src/pages/Settings.tsx
@@ -9,7 +9,7 @@ import { CodeBlock } from "../components/CodeBlock";
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
-
+
{title}
{description && {description}
}
{children}
@@ -118,9 +118,11 @@ export function SettingsPage() {
@@ -165,8 +173,10 @@ export function SettingsPage() {
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
inputMode="numeric"
+ autoComplete="one-time-code"
+ pattern="[0-9]*"
/>
-
+
Enable 2FA
@@ -198,9 +208,11 @@ export function SettingsPage() {
max={90}
value={autoDeleteDays}
onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
- onMouseUp={() => saveAutoDelete(true, autoDeleteDays)}
- onTouchEnd={() => saveAutoDelete(true, autoDeleteDays)}
+ // pointerup covers mouse, touch and pen in one handler.
+ onPointerUp={() => saveAutoDelete(true, autoDeleteDays)}
+ onKeyUp={() => saveAutoDelete(true, autoDeleteDays)}
className="w-full accent-[var(--color-primary)]"
+ aria-label="Retention window in days"
/>
)}
@@ -208,13 +220,15 @@ export function SettingsPage() {
- setDeleteOpen(true)}>
+ setDeleteOpen(true)}>
Delete account
@@ -227,10 +241,10 @@ export function SettingsPage() {
title="Disable 2FA"
footer={
<>
-
setDisable2faOpen(false)}>
+ setDisable2faOpen(false)}>
Cancel
-
+
Disable
>
@@ -239,6 +253,7 @@ export function SettingsPage() {
setDisablePw(e.target.value)}
/>
@@ -251,10 +266,15 @@ export function SettingsPage() {
title="Delete account"
footer={
<>
- setDeleteOpen(false)}>
+ setDeleteOpen(false)}>
Cancel
-
+
Permanently delete
>
@@ -268,6 +288,7 @@ export function SettingsPage() {
setDeletePw(e.target.value)}
/>
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
new file mode 100644
index 0000000..14e8eb5
--- /dev/null
+++ b/docker-compose.local.yml
@@ -0,0 +1,81 @@
+# Local / self-hosted stack — builds the image from this checkout instead of
+# pulling registry.reversed.dev. No .env file required: every variable has a
+# working localhost default below, so this comes up ready to set up.
+#
+# docker compose -f docker-compose.local.yml up -d --build
+#
+# Then open http://localhost:5000 and register — the first account becomes ADMIN.
+#
+# Note: BuildKit builds the `build` and `runtime` stages in parallel, which can
+# exhaust Docker Desktop's memory limit during pnpm install. If `--build` fails
+# with "cannot allocate memory", warm the first stage on its own and retry:
+#
+# docker build --target build -t patchpass-build .
+# docker compose -f docker-compose.local.yml up -d --build
+
+services:
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: runtime
+ image: patchpass/core:local
+ restart: unless-stopped
+ ports:
+ - "${PORT:-5000}:${PORT:-5000}"
+ environment:
+ NODE_ENV: production
+ TZ: ${TZ:-Europe/Berlin}
+ PORT: ${PORT:-5000}
+
+ DATABASE_URL: postgresql://patchpass:patchpass@database:5432/patchpass
+
+ # Cookie scope + public URLs. Change these when you expose the instance
+ # on a real hostname or a LAN IP.
+ DOMAIN: ${DOMAIN:-localhost}
+ UI_URL: ${UI_URL:-http://localhost:5000}
+ REACT_APP_API_URL: ${REACT_APP_API_URL:-http://localhost:5000}
+ CORS_URLS: ${CORS_URLS:-http://localhost:5000,http://localhost:3000}
+
+ # Signs approval receipts (HMAC-SHA256) and hashes sessions.
+ # DEV DEFAULT — override with a real 32-byte hex secret for anything
+ # beyond local testing: openssl rand -hex 32
+ INSTANCE_SECRET: ${INSTANCE_SECRET:-0000000000000000000000000000000000000000000000000000000000000000}
+
+ RATELIMIT: ${RATELIMIT:-1000}
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ REQUEST_DEBUGGING: ${REQUEST_DEBUGGING:-false}
+ RESPONSE_DEBUGGING: ${RESPONSE_DEBUGGING:-false}
+ depends_on:
+ database:
+ condition: service_healthy
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - node -e "fetch('http://127.0.0.1:'+(process.env.PORT||5000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 40s
+
+ database:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: patchpass
+ POSTGRES_PASSWORD: patchpass
+ POSTGRES_DB: patchpass
+ # Exposed so you can point Prisma Studio or psql at it. Host port 5434 keeps
+ # it clear of the dev database on 5433.
+ ports:
+ - "${DB_PORT:-5434}:5432"
+ volumes:
+ - patchpass_local_db:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U patchpass -d patchpass"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+volumes:
+ patchpass_local_db: