Compare commits

...

2 Commits

Author SHA1 Message Date
space 0c49b132ee feat(ui): make the UI feel native on mobile
Deploy / Build (pull_request) Successful in 49s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
Deploy / Test & Lint (pull_request) Successful in 34s
Reworks the frontend around phone-first interaction patterns and makes the
app installable to a home screen.

Shell
- Bottom tab bar (Home / Requests / Agents / Alerts / Settings) below `md`,
  with the unread badge moved onto the Alerts tab; the bell stays on desktop.
- Account menu opens as a bottom sheet on phones, dropdown on desktop.
- Safe-area insets throughout: `viewport-fit=cover` plus `env(safe-area-inset-*)`
  on the sticky header, tab bar and sheet footers.

Sheets
- New `Sheet` primitive: drag-to-dismiss bottom sheet on phones, centred dialog
  from `sm` up. `Modal` now delegates to it, so every dialog inherits the
  gesture, the scroll lock and the safe-area padding.
- Portalled to `<body>` — an ancestor with a transform (the page fade-in) was
  otherwise becoming the containing block and displacing the fixed overlay.
- Scroll lock pins `<body>` and restores position, which iOS needs; plain
  `overflow: hidden` still rubber-bands there.
- `ConfirmSheet` replaces `window.confirm` for destructive actions.

Screens
- Request detail gets a sticky decision bar above the tab bar; the sidebar
  decision card is now desktop-only.
- Requests state filter becomes a swipeable pill row instead of a select.
- Agent cards show two primary actions plus an overflow sheet on phones.
- Diffs and code blocks contain their horizontal overscroll so a sideways swipe
  no longer triggers browser back; diffs gain a line-wrap toggle.
- `min-w-0` on grid tracks — items default to `min-width: auto`, so truncated
  meta lines were widening columns past the viewport at 320px.

Touch and input
- 44px minimum touch targets, `:active` press feedback, no tap highlight.
- 16px inputs on coarse pointers so iOS stops zooming on focus.
- autocomplete/inputmode hints so password managers and keyboards behave.
- `overscroll-behavior-y: none` disables pull-to-refresh; motion respects
  `prefers-reduced-motion`.

PWA
- Manifest, generated app icons (192/512/apple-touch) and standalone display
  metadata, so the app installs to a home screen without browser chrome.
- Backend serves `.webmanifest` as `application/manifest+json`; rjweb's type
  map has no entry for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 00:43:48 +02:00
space 5ddb56ef97 build: add a compose file that builds the image locally
docker-compose.yml pulls registry.reversed.dev. This variant builds from the
checkout instead and needs no .env — every variable falls back to a working
localhost default, so the stack comes up ready to register the first admin.

Also exposes Postgres on 5434 (clear of the dev database on 5433) and adds a
backend healthcheck so `depends_on` ordering is meaningful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 00:43:35 +02:00
34 changed files with 1995 additions and 568 deletions
+6 -1
View File
@@ -86,7 +86,12 @@ server.notFound(async (ctr) => {
// Serve a matching build file (assets, favicons, etc.) if present.
const file = resolveStaticFile(path);
if (file) return ctr.status(200, "OK").printFile(file, { addTypes: true });
if (file) {
// rjweb's type map has no entry for .webmanifest, and browsers want the
// PWA manifest served as application/manifest+json.
if (file.endsWith(".webmanifest")) ctr.headers.set("Content-Type", "application/manifest+json");
return ctr.status(200, "OK").printFile(file, { addTypes: !file.endsWith(".webmanifest") });
}
// SPA fallback — hand any other route to the React app.
if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true });
+22 -1
View File
@@ -117,7 +117,8 @@ Backend/
migrations/
UI/ React + Tailwind frontend (Vite), builds to UI/build/
Dockerfile multi-stage build (UI then backend)
docker-compose.yml backend + Postgres
docker-compose.yml backend (pre-built image) + Postgres
docker-compose.local.yml same stack, built from this checkout
example.env
.gitea/workflows/deploy.yml
```
@@ -155,6 +156,26 @@ docker compose up -d
# Open http://localhost:5000 — the first account to register becomes admin.
```
### Building the image locally
To run from this checkout instead of the pre-built image — no `.env` needed, every
variable falls back to a working localhost default:
```bash
docker compose -f docker-compose.local.yml up -d --build
```
BuildKit builds both Dockerfile stages in parallel, which can exceed Docker
Desktop's memory limit during `pnpm install`. If the build fails with "cannot
allocate memory", warm the first stage on its own and retry:
```bash
docker build --target build -t patchpass-build .
```
> The local compose file ships a placeholder `INSTANCE_SECRET`. Override it before
> using the instance for anything real.
---
## Development setup
+20 -2
View File
@@ -2,9 +2,27 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%85%3C/text%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover lets the app paint under the notch/home indicator; we
re-inset content with env(safe-area-inset-*) in index.css.
interactive-widget keeps fixed chrome above the on-screen keyboard. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="description" content="PatchPass — a human approval layer for AI agents." />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0a0e17" />
<meta name="color-scheme" content="dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="PatchPass" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<title>PatchPass</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#172038" />
<stop offset="1" stop-color="#0a0e17" />
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#6d8bff" stop-opacity="0.28" />
<stop offset="1" stop-color="#6d8bff" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="512" height="512" rx="113" fill="url(#bg)" />
<rect width="512" height="512" rx="113" fill="url(#glow)" />
<path
d="M141 264 L223 344 L376 172"
fill="none"
stroke="#8aa4ff"
stroke-width="100"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 721 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "PatchPass",
"short_name": "PatchPass",
"description": "A human approval layer for AI agents. Review changes, approve intent, let agents proceed.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#0a0e17",
"theme_color": "#0a0e17",
"categories": ["developer", "productivity", "utilities"],
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
],
"shortcuts": [
{ "name": "Awaiting review", "url": "/", "description": "Requests waiting for your review" },
{ "name": "All requests", "url": "/requests", "description": "Full change request history" },
{ "name": "Agents", "url": "/agents", "description": "Manage your agents and API keys" }
]
}
+33 -14
View File
@@ -47,9 +47,7 @@ export function AgentFormModal({
icon_url: iconUrl.trim() || null,
max_pending_requests: maxPending,
};
const saved = editing
? await agentsApi.update(agent!.id, payload)
: await agentsApi.create(payload);
const saved = editing ? await agentsApi.update(agent!.id, payload) : await agentsApi.create(payload);
toast.success(editing ? "Agent updated" : "Agent created");
onSaved(saved, !editing);
onClose();
@@ -68,17 +66,17 @@ export function AgentFormModal({
title={editing ? "Edit agent" : "New agent"}
footer={
<>
<Button variant="ghost" onClick={onClose}>
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} loading={loading}>
<Button className="flex-1 sm:flex-none" onClick={submit} loading={loading}>
{editing ? "Save" : "Create agent"}
</Button>
</>
}
>
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea
label="Description"
value={description}
@@ -90,18 +88,26 @@ export function AgentFormModal({
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://…"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
/>
<Input
label="Icon URL (optional)"
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
placeholder="https://…/icon.png"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
/>
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<div className="mb-1.5 flex items-center justify-between text-sm">
<span className="text-muted">Max pending requests</span>
<span className="font-medium text-text">{maxPending}</span>
<span className="font-medium text-text tabular-nums">{maxPending}</span>
</div>
<input
type="range"
@@ -110,8 +116,9 @@ export function AgentFormModal({
value={maxPending}
onChange={(e) => setMaxPending(Number(e.target.value))}
className="w-full accent-[var(--color-primary)]"
aria-label="Max pending requests"
/>
<p className="mt-1 text-xs text-faint">
<p className="mt-1.5 text-xs text-faint">
How many requests this agent may have awaiting review at once (110).
</p>
</div>
@@ -132,7 +139,7 @@ export function ConnectModal({
}: {
open: boolean;
onClose: () => void;
agent: Agent;
agent: Agent | null;
revealedKey?: string | null;
}) {
const [tab, setTab] = useState<Tab>("openclaw");
@@ -194,11 +201,23 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
];
return (
<Modal open={open} onClose={onClose} title={`Connect "${agent.name}"`} wide footer={<Button onClick={onClose}>Done</Button>}>
<Modal
open={open}
onClose={onClose}
title={agent ? `Connect "${agent.name}"` : "Connect"}
wide
footer={
<Button className="w-full sm:w-auto" onClick={onClose}>
Done
</Button>
}
>
<div className="space-y-4">
{hasKey ? (
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3">
<p className="text-xs font-semibold text-pending">Save this API key now it won't be shown again.</p>
<p className="text-xs font-semibold text-pending">
Save this API key now it won't be shown again.
</p>
<div className="mt-2">
<CodeBlock code={revealedKey!} />
</div>
@@ -210,12 +229,12 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
</p>
)}
<div className="flex gap-1 overflow-x-auto border-b border-border">
<div className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-4 sm:mx-0 sm:px-0">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`shrink-0 px-3 py-2 text-sm font-medium transition ${
className={`min-h-11 shrink-0 px-3 text-sm font-medium transition ${
tab === t.id ? "border-b-2 border-primary text-text" : "text-muted hover:text-text"
}`}
>
+66 -30
View File
@@ -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 (
<pre className="overflow-x-auto rounded-lg border border-border bg-bg p-3 text-xs leading-relaxed">
<pre
className={`scroll-x rounded-lg border border-border bg-bg p-2.5 text-[11px] leading-relaxed sm:p-3 sm:text-xs ${
wrap ? "overflow-x-hidden" : ""
}`}
>
<code>
{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 (
<div key={i} className={`whitespace-pre px-1 ${cls}`}>
<div
key={i}
className={`px-1 ${wrap ? "whitespace-pre-wrap break-words" : "whitespace-pre"} ${cls}`}
>
{line || " "}
</div>
);
@@ -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 (
<div className="flex flex-wrap items-center gap-2 text-sm">
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
<code className="diff-del max-w-full break-all rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
<span className="text-faint"></span>
<code className="diff-add rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
<code className="diff-add max-w-full break-all rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
{delta && <span className="rounded bg-primary-dim/40 px-2 py-0.5 text-xs text-primary">{delta}</span>}
{contentType && <span className="text-xs text-faint">({contentType})</span>}
</div>
@@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u
}
function typeChip(label: string, color: string) {
return <span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
return <span className={`shrink-0 rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
}
export function ChangeCard({ change, index }: { change: Change; index: number }) {
const [wrap, setWrap] = useState(false);
return (
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-mono text-faint">#{index + 1}</span>
<div className="rounded-xl border border-border bg-surface-raised/50 p-3 sm:p-4">
<div className="mb-3 flex items-start gap-2">
<span className="mt-0.5 shrink-0 font-mono text-xs text-faint">#{index + 1}</span>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
{change.type === "unified_diff" && (
<>
{typeChip("diff", "bg-primary-dim/40 text-primary")}
<code className="min-w-0 break-all text-sm text-text">{change.path}</code>
</>
)}
{change.type === "config" && (
<>
{typeChip("config", "bg-accent/15 text-accent")}
<code className="min-w-0 break-all text-sm text-text">{change.path}</code>
</>
)}
{change.type === "custom" && (
<>
{typeChip("custom", "bg-changes/15 text-changes")}
<span className="min-w-0 break-words text-sm text-text">{change.label}</span>
</>
)}
</div>
{/* Wrapping beats horizontal scrolling for long lines on a phone. */}
{change.type === "unified_diff" && (
<>
{typeChip("diff", "bg-primary-dim/40 text-primary")}
<code className="break-all text-sm text-text">{change.path}</code>
</>
)}
{change.type === "config" && (
<>
{typeChip("config", "bg-accent/15 text-accent")}
<code className="break-all text-sm text-text">{change.path}</code>
</>
)}
{change.type === "custom" && (
<>
{typeChip("custom", "bg-changes/15 text-changes")}
<span className="text-sm text-text">{change.label}</span>
</>
<button
onClick={() => 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"
}`}
>
<WrapIcon className="h-[18px] w-[18px]" />
</button>
)}
</div>
{change.type === "unified_diff" && <DiffView content={change.content} />}
{change.type === "unified_diff" && <DiffView content={change.content} wrap={wrap} />}
{change.type === "config" && (
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
)}
+16 -5
View File
@@ -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 (
<div className="relative">
{language && (
<span className="absolute left-3 top-2 text-[10px] uppercase tracking-wide text-faint">
<span className="absolute left-3 top-2.5 text-[10px] uppercase tracking-wide text-faint">
{language}
</span>
)}
<button
onClick={copy}
className="absolute right-2 top-2 rounded-md border border-border-strong bg-surface px-2 py-1 text-xs text-muted hover:text-text"
aria-label={copied ? "Copied" : "Copy to clipboard"}
className={`tap-sm absolute right-1.5 top-1.5 z-10 flex h-9 items-center gap-1.5 rounded-lg border border-border-strong px-2.5 text-xs transition ${
copied ? "bg-approved/15 text-approved" : "bg-surface text-muted hover:text-text"
}`}
>
{copied ? "Copied!" : "Copy"}
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
<span className="hidden xs:inline">{copied ? "Copied" : "Copy"}</span>
</button>
<pre className={`overflow-x-auto rounded-lg border border-border bg-bg p-3 ${language ? "pt-7" : ""} text-xs leading-relaxed text-text`}>
<pre
className={`scroll-x rounded-lg border border-border bg-bg p-3 pr-16 ${
language ? "pt-8" : ""
} text-[11px] leading-relaxed text-text sm:text-xs`}
>
<code>{code}</code>
</pre>
</div>
+52
View File
@@ -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 (
<Sheet
open={open}
onClose={onClose}
onSubmit={onConfirm}
title={title}
footer={
<>
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
Cancel
</Button>
<Button
variant={danger ? "danger" : "primary"}
className="flex-1 sm:flex-none"
onClick={onConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</>
}
>
<p className="text-sm leading-relaxed text-muted">{message}</p>
</Sheet>
);
}
+34 -14
View File
@@ -9,7 +9,10 @@ type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
const MAX = 500;
const meta: Record<Decision, { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }> = {
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 | null>(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={
<>
<Button variant="ghost" onClick={onClose}>
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
Cancel
</Button>
<Button variant={m.variant} onClick={submit} loading={loading} disabled={tooLong || missingRequired}>
<Button
variant={m.variant}
className="flex-1 sm:flex-none"
onClick={submit}
loading={loading}
disabled={tooLong || missingRequired}
>
{m.verb}
</Button>
</>
@@ -90,19 +105,22 @@ export function DecisionModal({
>
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
<p className="text-sm font-medium text-text">{request.title}</p>
<p className="text-sm font-medium leading-snug text-text">{request.title}</p>
<p className="mt-1 text-xs text-muted">
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
{request.agent?.name}
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name}
</p>
</div>
<p className="text-sm text-muted">{m.blurb}</p>
<div>
<div className="mb-1 flex items-center justify-between">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm text-muted">
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
</span>
<span className={`text-xs ${tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"}`}>
<span
className={`text-xs tabular-nums ${
tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"
}`}
>
{comment.length}/{MAX}
</span>
</div>
@@ -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"
}`}
/>
+242 -83
View File
@@ -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 (
<div className="min-h-screen">
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-2 px-3 py-3 sm:px-4">
<div className="flex min-h-[100dvh] flex-col">
<header
data-chrome
className="pt-safe px-safe sticky top-0 z-30 border-b border-border bg-bg/85 backdrop-blur-xl"
>
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-2 px-3 sm:h-16 sm:px-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link to="/" className="flex items-center gap-2">
<span className="hidden text-xl xs:inline"></span>
<Link to="/" className="tap-sm flex items-center gap-2">
<img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" />
<span className="text-base font-bold tracking-tight sm:text-lg">
Patch<span className="text-primary">Pass</span>
</span>
@@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) {
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<NotificationBell />
<div className="relative">
<button
onClick={() => setMenuOpen((o) => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
{user?.display_name?.[0]?.toUpperCase()}
</span>
<span className="hidden text-text sm:inline">{user?.display_name}</span>
</button>
{menuOpen && (
<div
className="animate-fade-in absolute right-0 mt-2 w-44 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl"
onMouseLeave={() => setMenuOpen(false)}
>
<Link
to="/settings"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<Link
to="/notifications"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text md:hidden"
onClick={() => setMenuOpen(false)}
>
Notifications
</Link>
{user?.role === "ADMIN" && (
<Link
to="/admin"
className="block px-4 py-2 text-sm text-accent hover:bg-surface-raised"
onClick={() => setMenuOpen(false)}
>
Admin
</Link>
)}
<button
onClick={async () => {
await logout();
navigate("/login");
}}
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
>
Log out
</button>
</div>
)}
{/* Below md the bell is replaced by the Alerts tab. */}
<div className="hidden md:block">
<NotificationBell />
</div>
<AccountMenu />
</div>
</div>
{/* mobile nav */}
<nav className="flex items-center justify-between gap-1 border-t border-border px-3 py-2 md:hidden sm:px-4">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-lg px-2 py-1.5 text-sm font-medium ${
isActive ? "bg-surface-raised text-text" : "text-muted"
}`
}
>
{item.label}
</NavLink>
))}
</nav>
</header>
<main className="mx-auto max-w-6xl px-3 py-5 sm:px-4 sm:py-8">{children}</main>
<main className="mx-auto w-full max-w-6xl flex-1 px-3 py-5 sm:px-4 sm:py-8">{children}</main>
<footer className="mx-auto max-w-6xl px-4 py-8 text-center text-xs text-faint">
<div className="flex flex-wrap items-center justify-center gap-3">
<Link to="/privacy" className="hover:text-muted">
<div className="flex flex-wrap items-center justify-center gap-x-3">
<Link to="/privacy" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
Privacy Policy
</Link>
<span>·</span>
<Link to="/terms" className="hover:text-muted">
<span aria-hidden>·</span>
<Link to="/terms" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
Terms of Service
</Link>
<span>·</span>
<span>PatchPass review changes, approve intent, let agents proceed.</span>
<span className="hidden sm:inline" aria-hidden>
·
</span>
<span className="w-full sm:w-auto">
PatchPass review changes, approve intent, let agents proceed.
</span>
</div>
</footer>
{/* Keeps the last of the page clear of the fixed tab bar. */}
<div
className="h-[calc(var(--bottom-nav-h)_+_var(--safe-bottom))] shrink-0 md:hidden"
aria-hidden
/>
<BottomTabs />
</div>
);
}
function BottomTabs() {
const { unreadCount } = useNotifications();
return (
<nav
data-chrome
className="pb-safe px-safe fixed inset-x-0 bottom-0 z-40 border-t border-border bg-bg/90 backdrop-blur-xl md:hidden"
>
<div className="mx-auto flex h-[var(--bottom-nav-h)] max-w-md items-stretch">
{tabs.map(({ to, label, Icon, end, badge }) => (
<NavLink key={to} to={to} end={end} className="flex flex-1 items-stretch">
{({ isActive }) => (
<span
className={`tap-sm flex w-full flex-col items-center justify-center gap-1 ${
isActive ? "text-primary" : "text-faint"
}`}
>
<span className="relative">
<Icon
className="h-[22px] w-[22px] transition-transform"
strokeWidth={isActive ? 2.2 : 1.7}
/>
{badge && unreadCount > 0 && (
<span className="absolute -right-2.5 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</span>
<span className="text-[10px] font-medium leading-none">{label}</span>
</span>
)}
</NavLink>
))}
</div>
</nav>
);
}
function AccountMenu() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => 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}
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
{initial}
</span>
<span className="hidden text-text sm:inline">{user?.display_name}</span>
</button>
{/* Desktop: dropdown. */}
{open && (
<div className="animate-fade-in absolute right-0 mt-2 hidden w-48 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl sm:block">
<MenuLink to="/settings" onClick={() => setOpen(false)}>
Settings
</MenuLink>
{user?.role === "ADMIN" && (
<MenuLink to="/admin" onClick={() => setOpen(false)} accent>
Admin
</MenuLink>
)}
<button
onClick={signOut}
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
>
Log out
</button>
</div>
)}
{/* Mobile: bottom sheet, which is reachable one-handed. */}
<div className="sm:hidden">
<Sheet open={open} onClose={() => setOpen(false)} padded={false}>
<div className="flex items-center gap-3 px-5 pb-4 pt-3">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-primary-dim/50 text-base font-semibold text-primary">
{initial}
</span>
<div className="min-w-0">
<p className="truncate font-semibold text-text">{user?.display_name}</p>
<p className="truncate text-xs text-muted">@{user?.username}</p>
</div>
</div>
<div className="border-t border-border">
<SheetRow
icon={<SettingsIcon className="h-5 w-5" />}
label="Settings"
onClick={() => {
setOpen(false);
navigate("/settings");
}}
/>
{user?.role === "ADMIN" && (
<SheetRow
icon={<ShieldIcon className="h-5 w-5" />}
label="Admin"
accent
onClick={() => {
setOpen(false);
navigate("/admin");
}}
/>
)}
<SheetRow
icon={<LogOutIcon className="h-5 w-5" />}
label="Log out"
danger
onClick={signOut}
/>
</div>
</Sheet>
</div>
</div>
);
}
function MenuLink({
to,
onClick,
accent,
children,
}: {
to: string;
onClick: () => void;
accent?: boolean;
children: ReactNode;
}) {
return (
<Link
to={to}
onClick={onClick}
className={`block px-4 py-2 text-sm hover:bg-surface-raised ${
accent ? "text-accent" : "text-muted hover:text-text"
}`}
>
{children}
</Link>
);
}
function SheetRow({
icon,
label,
onClick,
accent,
danger,
}: {
icon: ReactNode;
label: string;
onClick: () => void;
accent?: boolean;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
className={`flex min-h-14 w-full items-center gap-3.5 border-b border-border/60 px-5 text-left text-[15px] font-medium transition active:bg-surface-raised ${
danger ? "text-rejected" : accent ? "text-accent" : "text-text"
}`}
>
<span className={danger ? "" : accent ? "" : "text-muted"}>{icon}</span>
{label}
</button>
);
}
+10 -38
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className={`animate-fade-in relative z-10 flex max-h-[92dvh] w-full flex-col ${wide ? "max-w-3xl" : "max-w-lg"} rounded-t-2xl border border-border-strong bg-surface shadow-2xl sm:max-h-[90vh] sm:rounded-2xl`}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{children}</div>
{footer && <div className="flex flex-wrap justify-end gap-2 border-t border-border px-4 py-3 sm:px-5 sm:py-4">{footer}</div>}
</div>
</div>
<Sheet open={open} onClose={onClose} onSubmit={onSubmit} title={title} footer={footer} wide={wide}>
{children}
</Sheet>
);
}
+5 -10
View File
@@ -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() {
<div className="relative" ref={ref}>
<button
onClick={() => 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"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<BellIcon className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="animate-fade-in fixed inset-x-3 top-14 z-40 mt-2 rounded-xl border border-border-strong bg-surface shadow-2xl sm:absolute sm:inset-x-auto sm:top-auto sm:w-80">
<div className="animate-fade-in absolute right-0 z-40 mt-2 w-80 rounded-xl border border-border-strong bg-surface shadow-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex gap-2 text-xs">
+188
View File
@@ -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<HTMLDivElement>(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 <body>: 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(
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4" role="dialog" aria-modal="true">
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
exiting ? "opacity-0" : "animate-backdrop-in"
}`}
style={{ opacity: exiting ? 0 : 1 - dismissProgress * 0.7 }}
onClick={onClose}
/>
<div
ref={panelRef}
className={`relative z-10 flex max-h-[92dvh] w-full flex-col border border-border-strong bg-surface shadow-2xl ${
wide ? "sm:max-w-3xl" : "sm:max-w-lg"
} rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl ${
exiting
? isMobile
? "" // slides out; fading as well reads as a glitch on phones
: "opacity-0"
: isMobile
? "animate-sheet-up"
: "animate-scale-in"
}`}
style={{
transform: panelTransform,
transition: dragging ? "none" : `transform ${EXIT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity 180ms ease`,
}}
>
{/* Grab area — the whole header is draggable, like iOS sheets. */}
<div
className="shrink-0 touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="flex justify-center pt-2 sm:hidden">
<span className="h-1 w-9 rounded-full bg-border-strong" />
</div>
{title && (
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5 sm:py-4">
<h2 className="min-w-0 truncate text-base font-semibold text-text sm:text-lg">{title}</h2>
<button
onClick={onClose}
className="tap-sm -mr-2 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-muted hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
{/* The home-indicator inset is folded into the padding with calc so it
adds to the base spacing instead of replacing it. */}
<div
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${
padded ? "px-4 py-4 sm:px-5" : ""
} ${footer ? "" : padded ? "pb-[calc(1rem_+_var(--safe-bottom))]" : "pb-safe"}`}
>
{children}
</div>
{footer && (
<div className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border px-4 pb-[calc(0.75rem_+_var(--safe-bottom))] pt-3 sm:px-5 sm:pb-[calc(1rem_+_var(--safe-bottom))] sm:pt-4">
{footer}
</div>
)}
</div>
</div>,
document.body,
);
}
+155
View File
@@ -0,0 +1,155 @@
import type { SVGProps } from "react";
// 24px stroke icons, sized by the consumer via className.
type Props = SVGProps<SVGSVGElement>;
function Base({ children, className = "h-6 w-6", ...props }: Props) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export const HomeIcon = (p: Props) => (
<Base {...p}>
<path d="M3 10.2 12 3l9 7.2V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
</Base>
);
export const RequestsIcon = (p: Props) => (
<Base {...p}>
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5z" />
<path d="m8 11.5 2.2 2.2L16 8" />
</Base>
);
export const AgentsIcon = (p: Props) => (
<Base {...p}>
<rect x="4" y="7" width="16" height="12" rx="3" />
<path d="M12 3v4M9 13h.01M15 13h.01M9.5 16.5h5" />
</Base>
);
export const BellIcon = (p: Props) => (
<Base {...p}>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</Base>
);
export const SettingsIcon = (p: Props) => (
<Base {...p}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87V9a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1" />
</Base>
);
export const ShieldIcon = (p: Props) => (
<Base {...p}>
<path d="M12 3l7 3v5.5c0 4.4-2.9 7.9-7 9.5-4.1-1.6-7-5.1-7-9.5V6z" />
<path d="m9 12 2 2 4-4" />
</Base>
);
export const LogOutIcon = (p: Props) => (
<Base {...p}>
<path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" />
<path d="m16 17 5-5-5-5M21 12H9" />
</Base>
);
export const ChevronLeftIcon = (p: Props) => (
<Base {...p}>
<path d="m15 18-6-6 6-6" />
</Base>
);
export const ChevronRightIcon = (p: Props) => (
<Base {...p}>
<path d="m9 18 6-6-6-6" />
</Base>
);
export const CloseIcon = (p: Props) => (
<Base {...p}>
<path d="M6 6l12 12M18 6 6 18" />
</Base>
);
export const CheckIcon = (p: Props) => (
<Base {...p}>
<path d="m5 13 4.5 4.5L19 7" />
</Base>
);
export const CopyIcon = (p: Props) => (
<Base {...p}>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15a2 2 0 0 1-1-1.7V6a2 2 0 0 1 2-2h7.3A2 2 0 0 1 15 5" />
</Base>
);
export const FilterIcon = (p: Props) => (
<Base {...p}>
<path d="M4 5h16M7 12h10M10 19h4" />
</Base>
);
export const MoreIcon = (p: Props) => (
<Base {...p}>
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</Base>
);
export const KeyIcon = (p: Props) => (
<Base {...p}>
<circle cx="8" cy="14" r="4" />
<path d="m11 11 8-8M17 5l2 2M14.5 7.5l2 2" />
</Base>
);
export const PowerIcon = (p: Props) => (
<Base {...p}>
<path d="M12 4v8M7.5 7a7 7 0 1 0 9 0" />
</Base>
);
export const TrashIcon = (p: Props) => (
<Base {...p}>
<path d="M4 7h16M10 4h4M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M10 11v6M14 11v6" />
</Base>
);
export const PencilIcon = (p: Props) => (
<Base {...p}>
<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" />
</Base>
);
export const PlugIcon = (p: Props) => (
<Base {...p}>
<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6zM12 17v4" />
</Base>
);
export const WrapIcon = (p: Props) => (
<Base {...p}>
<path d="M4 6h16M4 18h6" />
<path d="M4 12h13a3 3 0 1 1 0 6h-3" />
<path d="m12 15-2 3 2 3" />
</Base>
);
+60 -19
View File
@@ -21,7 +21,9 @@ export function Button({
<button
{...props}
disabled={props.disabled || loading}
className={`inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
// min-h-11 is the 44px touch target Apple and Google both recommend;
// desktop gets the tighter 40px.
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition duration-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 sm:min-h-10 ${variants[variant]} ${className}`}
>
{loading && <Spinner className="h-4 w-4" />}
{children}
@@ -54,68 +56,107 @@ export function Card({
);
}
export function Input({ label, hint, className = "", ...props }: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
export function Input({
label,
hint,
className = "",
...props
}: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
{label && <span className="mb-1.5 block text-sm text-muted">{label}</span>}
<input
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
className={`min-h-11 w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-base text-text outline-none transition-colors focus:border-primary sm:min-h-10 sm:text-sm ${className}`}
/>
{hint && <span className="mt-1 block text-xs text-faint">{hint}</span>}
{hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
</label>
);
}
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
export function Textarea({
label,
className = "",
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
{label && <span className="mb-1.5 block text-sm text-muted">{label}</span>}
<textarea
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-base text-text outline-none transition-colors focus:border-primary sm:text-sm ${className}`}
/>
</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 (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => 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"
>
<span
className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-primary" : "bg-border-strong"}`}
className={`relative h-7 w-12 shrink-0 rounded-full transition-colors sm:h-6 sm:w-11 ${
checked ? "bg-primary" : "bg-border-strong"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${checked ? "left-[22px]" : "left-0.5"}`}
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow-sm transition-all sm:h-5 sm:w-5 ${
checked ? "left-[22px] sm:left-[22px]" : "left-0.5"
}`}
/>
</span>
{label && <span className="text-sm text-text">{label}</span>}
{label && <span className="text-left text-sm text-text">{label}</span>}
</button>
);
}
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16 text-center">
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border px-5 py-12 text-center sm:py-16">
{icon && <div className="mb-3 text-4xl opacity-70">{icon}</div>}
<p className="text-text font-medium">{title}</p>
<p className="font-medium text-text">{title}</p>
{subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
</div>
);
}
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 (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4">
<div>
<h1 className="text-xl font-bold tracking-tight text-text sm:text-2xl">{title}</h1>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex w-full gap-2 sm:w-auto">{actions}</div>}
{/* Actions span the width on phones so they stay thumb-sized. */}
{actions && (
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:*:flex-none">{actions}</div>
)}
</div>
);
}
/** 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";
+27
View File
@@ -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)");
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect } from "react";
// `overflow: hidden` on <body> 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<CSSStyleDeclaration> = {};
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]);
}
+241 -7
View File
@@ -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;
}
}
+14 -2
View File
@@ -23,7 +23,7 @@ import { PrivacyPage, TermsPage } from "./pages/Legal";
function FullScreenLoader() {
return (
<div className="flex min-h-screen items-center justify-center text-primary">
<div className="flex min-h-[100dvh] items-center justify-center text-primary">
<Spinner className="h-8 w-8" />
</div>
);
@@ -66,7 +66,19 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<BrowserRouter>
<AuthProvider>
<App />
<ToastContainer position="bottom-right" theme="dark" newestOnTop />
<ToastContainer
position="bottom-right"
theme="dark"
newestOnTop
// Swipe-to-dismiss and a short auto-close read as native on a phone.
draggable
draggablePercent={35}
closeOnClick
closeButton={false}
hideProgressBar
autoClose={3200}
limit={3}
/>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
+115 -68
View File
@@ -4,6 +4,7 @@ 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 { relativeTime } from "../utils";
type Tab = "users" | "agents" | "settings" | "audit";
@@ -20,12 +21,12 @@ export function AdminPage() {
return (
<div>
<PageHeader title="Admin" subtitle="Platform administration." />
<div className="mb-6 flex gap-1 overflow-x-auto border-b border-border">
<div className="no-scrollbar -mx-3 mb-6 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-3 sm:mx-0 sm:px-0">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => 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"
}`}
>
@@ -45,6 +46,8 @@ function UsersTab() {
const { user: me } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [pendingDelete, setPendingDelete] = useState<AdminUser | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
@@ -75,13 +78,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 +95,54 @@ function UsersTab() {
return (
<div className="space-y-2">
{users.map((u) => (
<Card key={u.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-text">{u.display_name}</span>
<span className="text-xs text-faint">@{u.username}</span>
{u.role === "ADMIN" && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
ADMIN
</span>
)}
{u.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
<Card key={u.id} className="p-3.5 sm:p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-text">{u.display_name}</span>
<span className="text-xs text-faint">@{u.username}</span>
{u.role === "ADMIN" && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
ADMIN
</span>
)}
{u.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
</div>
<p className="mt-0.5 text-xs text-muted">
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
</p>
</div>
<p className="text-xs text-muted">
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
</p>
{u.id !== me?.id && (
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:flex-nowrap sm:*:flex-none">
<Button variant="ghost" onClick={() => setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
{u.role === "ADMIN" ? "Demote" : "Promote"}
</Button>
<Button variant="ghost" onClick={() => toggleDisabled(u)}>
{u.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => setPendingDelete(u)}>
Delete
</Button>
</div>
)}
</div>
{u.id !== me?.id && (
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
<Button variant="ghost" onClick={() => setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
{u.role === "ADMIN" ? "Demote" : "Promote"}
</Button>
<Button variant="ghost" onClick={() => toggleDisabled(u)}>
{u.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(u)}>
Delete
</Button>
</div>
)}
</Card>
))}
<ConfirmSheet
open={!!pendingDelete}
onClose={() => 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"
/>
</div>
);
}
@@ -131,6 +150,9 @@ function UsersTab() {
function AgentsTab() {
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
const [loading, setLoading] = useState(true);
const [pendingDelete, setPendingDelete] = useState<Agent | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
setAgents(await admin.agents());
@@ -151,13 +173,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 +190,42 @@ function AgentsTab() {
return (
<div className="space-y-2">
{agents.map((a) => (
<Card key={a.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-text">{a.name}</span>
<span className="text-xs text-faint">by @{a.owner.username}</span>
{a.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
<Card key={a.id} className="p-3.5 sm:p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-text">{a.name}</span>
<span className="text-xs text-faint">by @{a.owner.username}</span>
{a.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
</div>
{a.description && <p className="mt-0.5 text-xs text-muted">{a.description}</p>}
</div>
<div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:flex-nowrap sm:*:flex-none">
<Button variant="ghost" onClick={() => toggle(a)}>
{a.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => setPendingDelete(a)}>
Delete
</Button>
</div>
{a.description && <p className="text-xs text-muted">{a.description}</p>}
</div>
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
<Button variant="ghost" onClick={() => toggle(a)}>
{a.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
Delete
</Button>
</div>
</Card>
))}
<ConfirmSheet
open={!!pendingDelete}
onClose={() => 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"
/>
</div>
);
}
@@ -195,7 +233,10 @@ function AgentsTab() {
function SettingsTab() {
const [settings, setSettings] = useState<GlobalSettings | null>(null);
useEffect(() => {
admin.settings().then(setSettings).catch(() => {});
admin
.settings()
.then(setSettings)
.catch(() => {});
}, []);
const update = async (patch: Partial<GlobalSettings>) => {
@@ -211,15 +252,15 @@ function SettingsTab() {
if (!settings) return <Loader />;
return (
<div className="space-y-3">
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
<div>
<Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
<div className="min-w-0">
<p className="font-medium text-text">Enable registration</p>
<p className="text-sm text-muted">Allow new humans to create accounts.</p>
</div>
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
</Card>
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
<div>
<Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
<div className="min-w-0">
<p className="font-medium text-text">Enable requests</p>
<p className="text-sm text-muted">Allow agents to submit new change requests platform-wide.</p>
</div>
@@ -251,14 +292,20 @@ function AuditTab() {
<div>
<div className="space-y-1.5">
{logs.map((l) => (
<Card key={l.id} className="flex flex-wrap items-center gap-2 p-3 text-sm sm:flex-nowrap sm:gap-3">
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
<span className="min-w-0 flex-1 truncate text-muted">
{l.actor ? `@${l.actor.username}` : "system"}
{l.target_type && `${l.target_type}:${l.target_id}`}
{l.detail && ` · ${l.detail}`}
</span>
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">{relativeTime(l.created_at)}</span>
<Card key={l.id} className="p-3 text-sm">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 sm:flex-nowrap sm:gap-3">
<code className="shrink-0 rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">
{l.action}
</code>
<span className="min-w-0 flex-1 break-words text-muted sm:truncate">
{l.actor ? `@${l.actor.username}` : "system"}
{l.target_type && `${l.target_type}:${l.target_id}`}
{l.detail && ` · ${l.detail}`}
</span>
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">
{relativeTime(l.created_at)}
</span>
</div>
</Card>
))}
</div>
@@ -267,7 +314,7 @@ function AuditTab() {
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<span className="text-sm text-muted">
<span className="text-sm text-muted tabular-nums">
Page {page} of {totalPages}
</span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
+148 -28
View File
@@ -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<Agent[]>([]);
@@ -13,6 +18,9 @@ export function AgentsPage() {
const [editing, setEditing] = useState<Agent | null>(null);
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
const [revealedKey, setRevealedKey] = useState<string | null>(null);
const [menuAgent, setMenuAgent] = useState<Agent | null>(null);
const [confirm, setConfirm] = useState<Confirm | null>(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 (
<div>
<PageHeader
@@ -97,47 +116,65 @@ export function AgentsPage() {
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API."
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 sm:gap-4 md:grid-cols-2">
{agents.map((a) => (
<Card key={a.id} className="p-4">
<Card key={a.id} className="min-w-0 p-3.5 sm:p-4">
<div className="flex items-start gap-3">
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-text">{a.name}</h3>
{a.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
<span className="shrink-0 rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
</div>
{a.description && <p className="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>}
<p className="mt-1 text-xs text-faint">
<p className="mt-1 truncate text-xs text-faint">
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
<code>{a.api_key_masked}</code>
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
{/* Phones: two primary actions plus an overflow sheet, rather
than five wrapped ghost buttons. */}
<div className="mt-3.5 flex gap-2 sm:hidden">
<Button variant="secondary" className="flex-1" onClick={() => setConnectAgent(a)}>
Connect
</Button>
<Button variant="ghost" className="flex-1" onClick={() => openEdit(a)}>
Edit
</Button>
<Button
variant="ghost"
onClick={() => {
setEditing(a);
setFormOpen(true);
}}
className="w-11 shrink-0 px-0"
aria-label={`More actions for ${a.name}`}
onClick={() => setMenuAgent(a)}
>
<MoreIcon className="h-5 w-5" />
</Button>
</div>
<div className="mt-4 hidden flex-wrap gap-2 sm:flex">
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
Connect
</Button>
<Button variant="ghost" onClick={() => openEdit(a)}>
Edit
</Button>
<Button variant="ghost" onClick={() => regenerate(a)}>
<Button variant="ghost" onClick={() => setConfirm({ agent: a, kind: "regenerate" })}>
Regenerate key
</Button>
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
{a.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
<Button
variant="ghost"
className="text-rejected"
onClick={() => setConfirm({ agent: a, kind: "delete" })}
>
Delete
</Button>
</div>
@@ -146,23 +183,106 @@ export function AgentsPage() {
</div>
)}
<AgentFormModal
open={formOpen}
onClose={() => setFormOpen(false)}
agent={editing}
onSaved={onSaved}
<AgentFormModal open={formOpen} onClose={() => setFormOpen(false)} agent={editing} onSaved={onSaved} />
<ConnectModal
open={!!connectAgent}
onClose={() => {
setConnectAgent(null);
setRevealedKey(null);
}}
agent={connectAgent}
revealedKey={revealedKey}
/>
{connectAgent && (
<ConnectModal
open={!!connectAgent}
onClose={() => {
setConnectAgent(null);
setRevealedKey(null);
{/* Overflow actions (mobile) */}
<Sheet
open={!!menuAgent}
onClose={() => setMenuAgent(null)}
title={menuAgent?.name ?? ""}
padded={false}
>
<ActionRow
icon={<PencilIcon className="h-5 w-5" />}
label="Edit agent"
onClick={() => {
const a = menuAgent!;
setMenuAgent(null);
openEdit(a);
}}
agent={connectAgent}
revealedKey={revealedKey}
/>
)}
<ActionRow
icon={<KeyIcon className="h-5 w-5" />}
label="Regenerate key"
onClick={() => {
const a = menuAgent!;
setMenuAgent(null);
setConfirm({ agent: a, kind: "regenerate" });
}}
/>
<ActionRow
icon={<PowerIcon className="h-5 w-5" />}
label={menuAgent?.disabled ? "Enable agent" : "Disable agent"}
onClick={() => {
const a = menuAgent!;
setMenuAgent(null);
toggleDisabled(a);
}}
/>
<ActionRow
icon={<TrashIcon className="h-5 w-5" />}
label="Delete agent"
danger
onClick={() => {
const a = menuAgent!;
setMenuAgent(null);
setConfirm({ agent: a, kind: "delete" });
}}
/>
</Sheet>
<ConfirmSheet
open={!!confirm}
onClose={() => 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);
}}
/>
</div>
);
}
function ActionRow({
icon,
label,
onClick,
danger,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
className={`flex min-h-14 w-full items-center gap-3.5 border-b border-border/60 px-5 text-left text-[15px] font-medium transition active:bg-surface-raised ${
danger ? "text-rejected" : "text-text"
}`}
>
<span className={danger ? "" : "text-muted"}>{icon}</span>
{label}
</button>
);
}
+50 -32
View File
@@ -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."
/>
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="mb-7 grid grid-cols-2 gap-2.5 sm:mb-8 sm:grid-cols-4 sm:gap-3">
{summaryTiles.map((t) => (
<Card key={t.state} className="p-4">
<p className="text-3xl font-bold text-text">{counts[t.state] ?? 0}</p>
<p className="mt-1 text-xs text-muted">{t.label}</p>
<Card key={t.state} className="p-3.5 sm:p-4">
<p className="text-2xl font-bold text-text tabular-nums sm:text-3xl">
{counts[t.state] ?? 0}
</p>
<p className="mt-0.5 text-xs text-muted sm:mt-1">{t.label}</p>
</Card>
))}
</div>
<div className="grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* 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. */}
<div className="grid gap-7 lg:grid-cols-3 lg:gap-8">
<div className="min-w-0 lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Awaiting review</h2>
<Link to="/requests" className="text-sm text-primary hover:underline">
<Link to="/requests" className={textLinkClass}>
View all
</Link>
</div>
@@ -80,7 +86,7 @@ export function DashboardPage() {
subtitle="No requests are waiting for your review right now."
/>
) : (
<div className="space-y-3">
<div className="space-y-2.5">
{pending.map((r) => (
<PendingRow key={r.request_id} request={r} />
))}
@@ -88,15 +94,19 @@ export function DashboardPage() {
)}
</div>
<div>
<div className="min-w-0">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Agents</h2>
<Link to="/agents" className="text-sm text-primary hover:underline">
<Link to="/agents" className={textLinkClass}>
Manage
</Link>
</div>
{agents.length === 0 ? (
<EmptyState icon="🤖" title="No agents yet" subtitle="Create an agent to start receiving requests." />
<EmptyState
icon="🤖"
title="No agents yet"
subtitle="Create an agent to start receiving requests."
/>
) : (
<div className="space-y-2">
{agents.map((a) => (
@@ -123,30 +133,38 @@ function PendingRow({ request }: { request: ChangeRequest }) {
const exp = expiresIn(request.expires_at);
return (
<Link to={`/requests/${request.request_id}`} className="block">
<Card className="p-4 transition hover:border-border-strong hover:bg-surface-raised/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 items-start gap-3">
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="break-words font-medium text-text">{request.title}</p>
{request.resubmitted && (
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
UPDATED
</span>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted">
{request.agent?.name} · {request.changes.length} change
{request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
</p>
</div>
<Card className="tap flex items-start gap-3 p-3.5 hover:border-border-strong hover:bg-surface-raised/40 active:bg-surface-raised/60 sm:p-4">
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="min-w-0 break-words font-medium leading-snug text-text">{request.title}</p>
{request.resubmitted && (
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
UPDATED
</span>
)}
</div>
<div className="flex self-start sm:shrink-0 sm:flex-col sm:items-end sm:gap-1">
<p className="mt-1 truncate text-xs text-muted">
{request.agent?.name} · {request.changes.length} change
{request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
</p>
{/* Stacked under the title on phones, where a side column would
squeeze the text to a couple of words per line. */}
<div className="mt-2 flex flex-wrap items-center gap-2 sm:hidden">
<StateBadge state={request.state} />
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>{exp.text}</span>
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>
{exp.text}
</span>
</div>
</div>
<div className="hidden shrink-0 flex-col items-end gap-1 sm:flex">
<StateBadge state={request.state} />
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>{exp.text}</span>
</div>
<ChevronRightIcon className="mt-1 h-5 w-5 shrink-0 text-faint sm:hidden" />
</Card>
</Link>
);
+37 -32
View File
@@ -2,23 +2,28 @@ import { Link } from "react-router-dom";
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mx-auto max-w-3xl px-4 py-12">
<Link to="/" className="mb-6 inline-block text-sm text-muted hover:text-text">
Back
</Link>
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text">
{children}
<div className="pt-safe pb-safe mx-auto max-w-3xl px-4">
<div className="py-10 sm:py-12">
<Link
to="/"
className="tap-sm -ml-1 mb-4 inline-flex min-h-11 items-center pr-3 text-sm text-muted hover:text-text sm:mb-6"
>
Back
</Link>
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text">
{children}
</div>
<footer className="mt-12 border-t border-border pt-6 text-xs text-faint">
<Link to="/privacy" className="hover:text-muted">
Privacy Policy
</Link>
<span className="mx-2">·</span>
<Link to="/terms" className="hover:text-muted">
Terms of Service
</Link>
</footer>
</div>
<footer className="mt-12 border-t border-border pt-6 text-xs text-faint">
<Link to="/privacy" className="hover:text-muted">
Privacy Policy
</Link>
<span className="mx-2">·</span>
<Link to="/terms" className="hover:text-muted">
Terms of Service
</Link>
</footer>
</div>
);
}
@@ -27,28 +32,28 @@ export function PrivacyPage() {
return (
<LegalShell title="Privacy Policy">
<p>
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.
</p>
<h2>Data we store</h2>
<p>
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.
</p>
<h2>Agent icons</h2>
<p>
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&nbsp;MB). The image itself is <strong>not</strong> 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&nbsp;MB). The image itself is <strong>not</strong> stored or cached only the
URL you provided is kept.
</p>
<h2>How your data is used</h2>
<p>
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.
</p>
<h2>Retention</h2>
<p>
@@ -58,8 +63,8 @@ export function PrivacyPage() {
<h2>Your rights (GDPR)</h2>
<p>
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.
</p>
<h2>Security</h2>
<p>
@@ -92,13 +97,13 @@ export function TermsPage() {
</p>
<h2>Rate limits</h2>
<p>
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.
</p>
<h2>Availability</h2>
<p>
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.
</p>
<h2>Changes</h2>
<p>These terms may be updated by the operator of your instance.</p>
+10 -4
View File
@@ -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
/>
<Input
@@ -52,6 +55,7 @@ export function LoginPage() {
type="password"
value={password}
onChange={(e) => 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 (
<div className="flex min-h-[100dvh] items-center justify-center p-3 sm:p-4">
<div className="w-full max-w-md">
<div className="pt-safe pb-safe flex min-h-[100dvh] items-center justify-center">
<div className="w-full max-w-md px-3 py-6 sm:px-4">
<div className="mb-8 text-center">
<div className="mb-3 text-4xl"></div>
<img src="/icon.svg" alt="" className="mx-auto mb-3 h-14 w-14 rounded-2xl" />
<h1 className="text-3xl font-bold tracking-tight">
Patch<span className="text-primary">Pass</span>
</h1>
+14 -5
View File
@@ -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) => (
<Card
key={n.id}
className={`flex cursor-pointer items-start gap-3 p-4 transition hover:bg-surface-raised/40 ${
className={`tap flex cursor-pointer items-start gap-3 p-3.5 hover:bg-surface-raised/40 active:bg-surface-raised/60 sm:p-4 ${
n.read ? "opacity-60" : ""
}`}
onClick={() => {
@@ -41,12 +42,20 @@ export function NotificationsPage() {
if (n.request_id) navigate(`/requests/${n.request_id}`);
}}
>
{!n.read && <span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />}
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${n.read ? "bg-transparent" : "bg-primary"}`}
/>
<div className="min-w-0 flex-1">
<p className="font-medium text-text">{n.title}</p>
<p className="text-sm text-muted">{n.message}</p>
<p className="font-medium leading-snug text-text">{n.title}</p>
<p className="mt-0.5 text-sm text-muted">{n.message}</p>
<p className="mt-1 text-xs text-faint sm:hidden">{relativeTime(n.created_at)}</p>
</div>
<span className="shrink-0 text-xs text-faint">{relativeTime(n.created_at)}</span>
<span className="hidden shrink-0 text-xs text-faint sm:block">
{relativeTime(n.created_at)}
</span>
{n.request_id && (
<ChevronRightIcon className="mt-0.5 h-5 w-5 shrink-0 text-faint sm:hidden" />
)}
</Card>
))}
</div>
+7 -1
View File
@@ -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
/>
<Input
label="Display name (optional)"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="name"
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
<Input
@@ -75,6 +80,7 @@ export function RegisterPage() {
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
/>
<Button type="submit" loading={loading} className="w-full">
+169 -119
View File
@@ -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 (
<div className="animate-fade-in">
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text">
Back to requests
</Link>
<div>
{/* The fade-in wrapper is kept inside: its transform would otherwise make
it the containing block for the fixed decision bar below. */}
<div className="animate-fade-in">
<Link
to="/requests"
className="tap-sm -ml-2 mb-3 inline-flex min-h-11 items-center gap-0.5 pr-3 text-sm text-muted hover:text-text sm:mb-4"
>
<ChevronLeftIcon className="h-5 w-5" />
Requests
</Link>
{request.resubmitted && request.state === "PENDING" && (
<div className="mb-4 flex items-center gap-3 rounded-xl border border-changes/40 bg-changes/10 px-4 py-3">
<span className="animate-pulse-ring rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
UPDATED
</span>
<p className="text-sm text-text">
This request was revised by the agent after you requested changes (update #{request.update_count}).
Please re-review the changes below.
</p>
</div>
)}
{request.resubmitted && request.state === "PENDING" && (
<div className="mb-4 flex flex-col gap-2 rounded-xl border border-changes/40 bg-changes/10 p-3.5 sm:flex-row sm:items-center sm:gap-3 sm:px-4 sm:py-3">
<span className="animate-pulse-ring w-fit rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
UPDATED
</span>
<p className="text-sm text-text">
This request was revised by the agent after you requested changes (update #
{request.update_count}). Please re-review the changes below.
</p>
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<div className="order-2 lg:order-1 lg:col-span-2">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-xl font-bold tracking-tight sm:text-2xl">{request.title}</h1>
<StateBadge state={request.state} />
<div className="grid gap-6 lg:grid-cols-3">
<div className="min-w-0 lg:col-span-2">
<div className="mb-4">
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
<h1 className="min-w-0 break-words text-xl font-bold leading-tight tracking-tight sm:text-2xl">
{request.title}
</h1>
<StateBadge state={request.state} className="mt-0.5 shrink-0" />
</div>
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
{request.description && (
<p className="mt-2 text-[15px] leading-relaxed text-muted sm:text-base">
{request.description}
</p>
)}
</div>
{request.comment && (
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
</p>
<p className="text-sm text-text">{request.comment}</p>
</div>
)}
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
Proposed changes ({request.changes.length})
</h2>
<ChangeList changes={request.changes} />
</div>
{request.comment && (
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
</p>
<p className="text-sm text-text">{request.comment}</p>
</div>
)}
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
Proposed changes ({request.changes.length})
</h2>
<ChangeList changes={request.changes} />
</div>
<div className="order-1 space-y-4 lg:order-2">
{canDecide && (
<Card className="space-y-2 p-4">
<p className="mb-1 text-sm font-semibold">Your decision</p>
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
Approve
</Button>
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
Request changes
</Button>
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
Reject
</Button>
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
{exp.text}
</p>
</Card>
)}
<Card className="p-4">
<p className="mb-3 text-sm font-semibold">Agent</p>
{request.agent ? (
<div className="flex items-center gap-3">
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{request.agent.name}</p>
{request.agent.website && (
<a
href={request.agent.website}
target="_blank"
rel="noreferrer"
className="truncate text-xs text-primary hover:underline"
>
{request.agent.website}
</a>
)}
</div>
</div>
) : (
<p className="text-sm text-muted">Unknown</p>
<div className="min-w-0 space-y-4">
{/* On phones this lives in the sticky bar at the bottom instead. */}
{canDecide && (
<Card className="hidden space-y-2 p-4 lg:block">
<p className="mb-1 text-sm font-semibold">Your decision</p>
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
Approve
</Button>
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
Request changes
</Button>
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
Reject
</Button>
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
{exp.text}
</p>
</Card>
)}
{request.agent?.description && (
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
)}
</Card>
<Card className="space-y-2 p-4 text-xs">
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
<Row
label="Content hash"
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
/>
<Row label="Created" value={relativeTime(request.created_at)} />
<Row label="Expires" value={formatDateTime(request.expires_at)} />
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
</Card>
{request.metadata && Object.keys(request.metadata).length > 0 && (
<Card className="p-4">
<p className="mb-2 text-sm font-semibold">Metadata</p>
<div className="space-y-1 text-xs">
{Object.entries(request.metadata).map(([k, v]) => (
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
))}
</div>
<p className="mb-3 text-sm font-semibold">Agent</p>
{request.agent ? (
<div className="flex items-center gap-3">
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{request.agent.name}</p>
{request.agent.website && (
<a
href={request.agent.website}
target="_blank"
rel="noreferrer"
className="block truncate text-xs text-primary hover:underline"
>
{request.agent.website}
</a>
)}
</div>
</div>
) : (
<p className="text-sm text-muted">Unknown</p>
)}
{request.agent?.description && (
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
)}
</Card>
)}
{request.receipt && (
<Card className="border-approved/30 bg-approved/5 p-4">
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
<span>🔏</span> Signed receipt
</p>
<div className="space-y-1 text-xs">
<Row label="Decision" value={request.receipt.payload.decision} />
<Row label="Algorithm" value={request.receipt.algorithm} />
<Row
label="Signature"
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>}
/>
</div>
<Card className="space-y-2 p-4 text-xs">
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
<Row
label="Content hash"
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
/>
<Row label="Created" value={relativeTime(request.created_at)} />
<Row label="Expires" value={formatDateTime(request.expires_at)} />
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
</Card>
)}
{request.metadata && Object.keys(request.metadata).length > 0 && (
<Card className="p-4">
<p className="mb-2 text-sm font-semibold">Metadata</p>
<div className="space-y-1 text-xs">
{Object.entries(request.metadata).map(([k, v]) => (
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
))}
</div>
</Card>
)}
{request.receipt && (
<Card className="border-approved/30 bg-approved/5 p-4">
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
<span>🔏</span> Signed receipt
</p>
<div className="space-y-1 text-xs">
<Row label="Decision" value={request.receipt.payload.decision} />
<Row label="Algorithm" value={request.receipt.algorithm} />
<Row
label="Signature"
value={
<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>
}
/>
</div>
</Card>
)}
</div>
</div>
</div>
<DecisionModal request={request} decision={decision} onClose={() => setDecision(null)} onDone={setRequest} />
{canDecide && (
<>
{/* Reserve room so the last card is never trapped under the bar. */}
<div className="h-28 lg:hidden" aria-hidden />
<div
data-chrome
className="pb-safe px-safe fixed inset-x-0 bottom-[calc(var(--bottom-nav-h)_+_var(--safe-bottom))] z-30 border-t border-border bg-bg/95 backdrop-blur-xl md:bottom-0 lg:hidden"
>
<div className="mx-auto max-w-2xl px-3 pb-3 pt-2.5 sm:px-4">
<p className={`mb-2 text-center text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>
{exp.text}
</p>
<div className="flex gap-2">
<Button variant="danger" className="flex-1" onClick={() => setDecision("REJECT")}>
Reject
</Button>
<Button variant="secondary" className="flex-1" onClick={() => setDecision("REQUEST_CHANGES")}>
Changes
</Button>
<Button variant="success" className="flex-[1.3]" onClick={() => setDecision("APPROVE")}>
Approve
</Button>
</div>
</div>
</div>
</>
)}
<DecisionModal
request={request}
decision={decision}
onClose={() => setDecision(null)}
onDone={setRequest}
/>
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<span className="shrink-0 text-faint">{label}</span>
<span className="break-all text-text sm:text-right">{value}</span>
</div>
+48 -39
View File
@@ -6,6 +6,7 @@ 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 { relativeTime } from "../utils";
const STATES: RequestState[] = [
@@ -31,7 +32,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 +60,51 @@ export function RequestsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const chip = (active: boolean) =>
`tap-sm min-h-9 shrink-0 snap-start 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 (
<div>
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<select
value={stateFilter}
onChange={(e) => {
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"
>
<option value="">All states</option>
{STATES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ").toLowerCase()}</option>
))}
</select>
<div className="hidden sm:contents">
<button
onClick={() => {
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
</button>
{STATES.map((s) => (
<div className="mb-4 space-y-2.5">
{/* Swipeable filter pills — bleeds to the screen edges on phones so the
row reads as scrollable. */}
<div className="no-scrollbar -mx-3 flex snap-x gap-2 overflow-x-auto overscroll-x-contain px-3 pb-0.5 sm:mx-0 sm:flex-wrap sm:px-0">
<button
key={s}
onClick={() => {
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
</button>
))}
{STATES.map((s) => (
<button
key={s}
onClick={() => {
setStateFilter(s);
setPage(1);
}}
className={chip(stateFilter === s)}
>
{s.replace("_", " ").toLowerCase()}
</button>
))}
</div>
<select
value={agentFilter}
onChange={(e) => {
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"
>
<option value="">All agents</option>
{agents.map((a) => (
@@ -124,25 +125,33 @@ export function RequestsPage() {
<div className="space-y-2">
{items.map((r) => (
<Link key={r.request_id} to={`/requests/${r.request_id}`} className="block">
<Card className="flex flex-col gap-2 p-3.5 transition hover:border-border-strong hover:bg-surface-raised/40 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 items-start gap-3">
<Card className="tap flex items-start gap-3 p-3.5 hover:border-border-strong hover:bg-surface-raised/40 active:bg-surface-raised/60 sm:items-center">
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="break-words font-medium text-text">{r.title}</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="min-w-0 break-words font-medium leading-snug text-text">
{r.title}
</p>
{r.resubmitted && r.state === "PENDING" && (
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
UPDATED
</span>
)}
</div>
<p className="truncate text-xs text-muted">
<p className="mt-1 truncate text-xs text-muted">
{r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
{relativeTime(r.created_at)}
</p>
<div className="mt-2 sm:hidden">
<StateBadge state={r.state} />
</div>
</div>
<div className="hidden shrink-0 sm:block">
<StateBadge state={r.state} />
</div>
<div className="self-start sm:shrink-0"><StateBadge state={r.state} /></div>
<ChevronRightIcon className="mt-1 h-5 w-5 shrink-0 text-faint sm:hidden" />
</Card>
</Link>
))}
@@ -154,7 +163,7 @@ export function RequestsPage() {
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<span className="text-sm text-muted">
<span className="text-sm text-muted tabular-nums">
Page {page} of {totalPages}
</span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
+35 -14
View File
@@ -9,7 +9,7 @@ import { CodeBlock } from "../components/CodeBlock";
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<Card className="p-4 sm:p-5">
<Card className="min-w-0 p-4 sm:p-5">
<h2 className="text-base font-semibold text-text">{title}</h2>
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
<div className={description ? "" : "mt-4"}>{children}</div>
@@ -118,9 +118,11 @@ export function SettingsPage() {
<div className="grid gap-5 lg:grid-cols-2">
<Section title="Profile">
<div className="space-y-3">
<Input label="Username" value={user?.username ?? ""} disabled />
<Input label="Username" value={user?.username ?? ""} disabled autoComplete="username" />
<Input label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<Button onClick={saveProfile}>Save profile</Button>
<Button className="w-full sm:w-auto" onClick={saveProfile}>
Save profile
</Button>
</div>
</Section>
@@ -129,17 +131,23 @@ export function SettingsPage() {
<Input
label="Current password"
type="password"
autoComplete="current-password"
value={curPw}
onChange={(e) => setCurPw(e.target.value)}
/>
<Input
label="New password"
type="password"
autoComplete="new-password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
hint="At least 8 characters."
/>
<Button onClick={savePassword} disabled={!curPw || newPw.length < 8}>
<Button
className="w-full sm:w-auto"
onClick={savePassword}
disabled={!curPw || newPw.length < 8}
>
Change password
</Button>
</div>
@@ -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]*"
/>
<div className="flex gap-2">
<div className="flex gap-2 *:flex-1 sm:*:flex-none">
<Button onClick={confirm2fa} disabled={totp.length !== 6}>
Enable 2FA
</Button>
@@ -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"
/>
</div>
)}
@@ -208,13 +220,15 @@ export function SettingsPage() {
</Section>
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
<a href={account.exportUrl} download>
<Button variant="secondary">Download export</Button>
<a href={account.exportUrl} download className="block sm:inline-block">
<Button variant="secondary" className="w-full sm:w-auto">
Download export
</Button>
</a>
</Section>
<Section title="Danger zone" description="Permanently delete your account and all associated data. This cannot be undone.">
<Button variant="danger" onClick={() => setDeleteOpen(true)}>
<Button variant="danger" className="w-full sm:w-auto" onClick={() => setDeleteOpen(true)}>
Delete account
</Button>
</Section>
@@ -227,10 +241,10 @@ export function SettingsPage() {
title="Disable 2FA"
footer={
<>
<Button variant="ghost" onClick={() => setDisable2faOpen(false)}>
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDisable2faOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={disable2fa}>
<Button variant="danger" className="flex-1 sm:flex-none" onClick={disable2fa}>
Disable
</Button>
</>
@@ -239,6 +253,7 @@ export function SettingsPage() {
<Input
label="Confirm your password"
type="password"
autoComplete="current-password"
value={disablePw}
onChange={(e) => setDisablePw(e.target.value)}
/>
@@ -251,10 +266,15 @@ export function SettingsPage() {
title="Delete account"
footer={
<>
<Button variant="ghost" onClick={() => setDeleteOpen(false)}>
<Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={deleteAccount} disabled={!deletePw}>
<Button
variant="danger"
className="flex-1 sm:flex-none"
onClick={deleteAccount}
disabled={!deletePw}
>
Permanently delete
</Button>
</>
@@ -268,6 +288,7 @@ export function SettingsPage() {
<Input
label="Confirm your password"
type="password"
autoComplete="current-password"
value={deletePw}
onChange={(e) => setDeletePw(e.target.value)}
/>
+81
View File
@@ -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: