Compare commits

...

5 Commits

Author SHA1 Message Date
space b68eac587a Merge pull request 'Improve Mobile UI' (#4) from feature/mobile-ui into main
Deploy / Build (push) Successful in 30s
Deploy / Test & Lint (push) Successful in 43s
Deploy / Build and Push Docker Image (push) Successful in 1m40s
Reviewed-on: #4
2026-08-01 01:50:10 +02:00
space ce2d9d9f77 fix(ui): un-cramp the requests filter row
Deploy / Build (pull_request) Successful in 1m15s
Deploy / Test & Lint (pull_request) Successful in 53s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
The state pills and the agent select sat 10px apart, and the pill row was
sliced mid-chip at the viewport edge with nothing to say it scrolled.

- New `ScrollRow`: fades whichever edge still has content behind it, so a
  clipped chip reads as "scroll for more" instead of as a broken layout.
  Reused for the admin and connect-modal tab strips.
- Drop scroll-snap from the pill row. It clamped the resting scrollLeft to the
  container's 12px padding, so the first chip sat flush against the screen edge
  with no gutter; flick-snapping through short chips felt wrong regardless.
- Breathing room: pills to select 10px -> 14px, filters to list 16px -> 20px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:19:30 +02:00
space 43040b2ac8 Allowed "*" as a CORS_DOMAINS entry which overrides the cors middleware
Deploy / Build (pull_request) Successful in 26s
Deploy / Test & Lint (pull_request) Successful in 32s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
2026-08-01 00:53:56 +02:00
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
36 changed files with 2093 additions and 572 deletions
+14 -2
View File
@@ -29,9 +29,16 @@ export const PORT = env.PORT;
export { prisma }; export { prisma };
const CORS_DOMAINS = env.CORS_URLS.split(",").map((s) => s.trim()); let CORS_DOMAINS = env.CORS_URLS.split(",").map((s) => s.trim());
CORS_DOMAINS.push(UI_URL); CORS_DOMAINS.push(UI_URL);
CORS_DOMAINS.push(REACT_APP_API_URL.replace(/\/+$/, "")); CORS_DOMAINS.push(REACT_APP_API_URL.replace(/\/+$/, ""));
// If an origin is "*", clear the list and add a star
// ? This is for PR-Previews especially, which have a dynamic origin and cannot be enumerated in advance
if (CORS_DOMAINS.includes("*")) {
CORS_DOMAINS = ["*"];
}
initCorsDomains(CORS_DOMAINS); initCorsDomains(CORS_DOMAINS);
if (env.NODE_ENV !== "test") { if (env.NODE_ENV !== "test") {
@@ -86,7 +93,12 @@ server.notFound(async (ctr) => {
// Serve a matching build file (assets, favicons, etc.) if present. // Serve a matching build file (assets, favicons, etc.) if present.
const file = resolveStaticFile(path); 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. // SPA fallback — hand any other route to the React app.
if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true }); if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true });
+20
View File
@@ -29,6 +29,26 @@ export const corsMiddleware = new Middleware<{}, {}>("Custom CORS", "1.0.0")
const origin = ctr.headers.get("origin"); const origin = ctr.headers.get("origin");
// if the list is only a single star, allow all origins
if (CORS_DOMAINS.length === 1 && CORS_DOMAINS[0] === "*") {
if (origin) {
ctr.headers.set("Access-Control-Allow-Origin", origin);
ctr.headers.set("Vary", "Origin");
ctr.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH");
ctr.headers.set(
"Access-Control-Allow-Headers",
ctr.headers.get("access-control-request-headers") || "content-type, x-api-key",
);
ctr.headers.set("Access-Control-Allow-Credentials", "true");
}
if (ctr.url.method === "OPTIONS") {
ctr.headers.set("Access-Control-Max-Age", "86400");
ctr.headers.set("Content-Length", "0");
return end(ctr.status(ctr.$status.NO_CONTENT).print(""));
}
return;
}
if (origin && !CORS_DOMAINS.includes(origin)) { if (origin && !CORS_DOMAINS.includes(origin)) {
// Agent/API traffic (no browser origin) is unaffected; only browser // Agent/API traffic (no browser origin) is unaffected; only browser
// requests from disallowed origins are blocked. // requests from disallowed origins are blocked.
+22 -1
View File
@@ -117,7 +117,8 @@ Backend/
migrations/ migrations/
UI/ React + Tailwind frontend (Vite), builds to UI/build/ UI/ React + Tailwind frontend (Vite), builds to UI/build/
Dockerfile multi-stage build (UI then backend) 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 example.env
.gitea/workflows/deploy.yml .gitea/workflows/deploy.yml
``` ```
@@ -155,6 +156,26 @@ docker compose up -d
# Open http://localhost:5000 — the first account to register becomes admin. # 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 ## Development setup
+20 -2
View File
@@ -2,9 +2,27 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <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" /> <!-- viewport-fit=cover lets the app paint under the notch/home indicator; we
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> 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." /> <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> <title>PatchPass</title>
</head> </head>
<body> <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" }
]
}
+35 -15
View File
@@ -5,6 +5,7 @@ import type { Agent } from "../api/types";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { Button, Input, Textarea } from "./ui"; import { Button, Input, Textarea } from "./ui";
import { CodeBlock } from "./CodeBlock"; import { CodeBlock } from "./CodeBlock";
import { ScrollRow } from "./ScrollRow";
// ── Create / edit form ─────────────────────────────────────────────────────────── // ── Create / edit form ───────────────────────────────────────────────────────────
@@ -47,9 +48,7 @@ export function AgentFormModal({
icon_url: iconUrl.trim() || null, icon_url: iconUrl.trim() || null,
max_pending_requests: maxPending, max_pending_requests: maxPending,
}; };
const saved = editing const saved = editing ? await agentsApi.update(agent!.id, payload) : await agentsApi.create(payload);
? await agentsApi.update(agent!.id, payload)
: await agentsApi.create(payload);
toast.success(editing ? "Agent updated" : "Agent created"); toast.success(editing ? "Agent updated" : "Agent created");
onSaved(saved, !editing); onSaved(saved, !editing);
onClose(); onClose();
@@ -68,17 +67,17 @@ export function AgentFormModal({
title={editing ? "Edit agent" : "New agent"} title={editing ? "Edit agent" : "New agent"}
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} loading={loading}> <Button className="flex-1 sm:flex-none" onClick={submit} loading={loading}>
{editing ? "Save" : "Create agent"} {editing ? "Save" : "Create agent"}
</Button> </Button>
</> </>
} }
> >
<div className="space-y-4"> <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 <Textarea
label="Description" label="Description"
value={description} value={description}
@@ -90,18 +89,26 @@ export function AgentFormModal({
value={website} value={website}
onChange={(e) => setWebsite(e.target.value)} onChange={(e) => setWebsite(e.target.value)}
placeholder="https://…" placeholder="https://…"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
/> />
<Input <Input
label="Icon URL (optional)" label="Icon URL (optional)"
value={iconUrl} value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)} onChange={(e) => setIconUrl(e.target.value)}
placeholder="https://…/icon.png" 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." hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
/> />
<div> <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="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> </div>
<input <input
type="range" type="range"
@@ -110,8 +117,9 @@ export function AgentFormModal({
value={maxPending} value={maxPending}
onChange={(e) => setMaxPending(Number(e.target.value))} onChange={(e) => setMaxPending(Number(e.target.value))}
className="w-full accent-[var(--color-primary)]" 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). How many requests this agent may have awaiting review at once (110).
</p> </p>
</div> </div>
@@ -132,7 +140,7 @@ export function ConnectModal({
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
agent: Agent; agent: Agent | null;
revealedKey?: string | null; revealedKey?: string | null;
}) { }) {
const [tab, setTab] = useState<Tab>("openclaw"); const [tab, setTab] = useState<Tab>("openclaw");
@@ -194,11 +202,23 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
]; ];
return ( 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"> <div className="space-y-4">
{hasKey ? ( {hasKey ? (
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3"> <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"> <div className="mt-2">
<CodeBlock code={revealedKey!} /> <CodeBlock code={revealedKey!} />
</div> </div>
@@ -210,19 +230,19 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
</p> </p>
)} )}
<div className="flex gap-1 overflow-x-auto border-b border-border"> <ScrollRow wrapperClassName="border-b border-border" className="gap-1" bleed={false}>
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
key={t.id} key={t.id}
onClick={() => setTab(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" tab === t.id ? "border-b-2 border-primary text-text" : "text-muted hover:text-text"
}`} }`}
> >
{t.label} {t.label}
</button> </button>
))} ))}
</div> </ScrollRow>
{tab === "openclaw" && ( {tab === "openclaw" && (
<div className="space-y-2"> <div className="space-y-2">
+66 -30
View File
@@ -1,9 +1,15 @@
import { useState } from "react";
import type { Change } from "../api/types"; 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"); const lines = content.split("\n");
return ( 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> <code>
{lines.map((line, i) => { {lines.map((line, i) => {
let cls = ""; let cls = "";
@@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) {
else if (line.startsWith("-")) cls = "diff-del"; else if (line.startsWith("-")) cls = "diff-del";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta"; else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
return ( 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 || " "} {line || " "}
</div> </div>
); );
@@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null {
return `${pct > 0 ? "+" : ""}${pct}%`; return `${pct > 0 ? "+" : ""}${pct}%`;
} }
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) { function BeforeAfter({
const delta = before,
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null; after,
contentType,
}: {
before: unknown;
after: unknown;
contentType?: string | null;
}) {
const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
return ( return (
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code> <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> <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>} {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>} {contentType && <span className="text-xs text-faint">({contentType})</span>}
</div> </div>
@@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u
} }
function typeChip(label: string, color: string) { 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 }) { export function ChangeCard({ change, index }: { change: Change; index: number }) {
const [wrap, setWrap] = useState(false);
return ( return (
<div className="rounded-xl border border-border bg-surface-raised/50 p-4"> <div className="rounded-xl border border-border bg-surface-raised/50 p-3 sm:p-4">
<div className="mb-3 flex flex-wrap items-center gap-2"> <div className="mb-3 flex items-start gap-2">
<span className="text-xs font-mono text-faint">#{index + 1}</span> <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" && ( {change.type === "unified_diff" && (
<> <button
{typeChip("diff", "bg-primary-dim/40 text-primary")} onClick={() => setWrap((w) => !w)}
<code className="break-all text-sm text-text">{change.path}</code> 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 ${
{change.type === "config" && ( wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted"
<> }`}
{typeChip("config", "bg-accent/15 text-accent")} >
<code className="break-all text-sm text-text">{change.path}</code> <WrapIcon className="h-[18px] w-[18px]" />
</> </button>
)}
{change.type === "custom" && (
<>
{typeChip("custom", "bg-changes/15 text-changes")}
<span className="text-sm text-text">{change.label}</span>
</>
)} )}
</div> </div>
{change.type === "unified_diff" && <DiffView content={change.content} />}
{change.type === "unified_diff" && <DiffView content={change.content} wrap={wrap} />}
{change.type === "config" && ( {change.type === "config" && (
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} /> <BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
)} )}
+16 -5
View File
@@ -1,30 +1,41 @@
import { useState } from "react"; import { useState } from "react";
import { CheckIcon, CopyIcon } from "./icons";
export function CodeBlock({ code, language }: { code: string; language?: string }) { export function CodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copy = async () => { const copy = async () => {
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { } catch {
/* ignore */ /* clipboard unavailable (insecure origin) — nothing useful to do */
} }
}; };
return ( return (
<div className="relative"> <div className="relative">
{language && ( {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} {language}
</span> </span>
)} )}
<button <button
onClick={copy} 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> </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> <code>{code}</code>
</pre> </pre>
</div> </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 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: { APPROVE: {
title: "Approve request", title: "Approve request",
verb: "Approve", verb: "Approve",
@@ -46,18 +49,24 @@ export function DecisionModal({
}) { }) {
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [loading, setLoading] = useState(false); 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(() => { useEffect(() => {
setComment(""); if (decision) {
setShown(decision);
setComment("");
}
}, [decision]); }, [decision]);
if (!decision) return null; if (!shown) return null;
const m = meta[decision]; const m = meta[shown];
const tooLong = comment.length > MAX; const tooLong = comment.length > MAX;
const missingRequired = m.commentRequired && comment.trim().length === 0; const missingRequired = m.commentRequired && comment.trim().length === 0;
const submit = async () => { const submit = async () => {
if (tooLong || missingRequired) return; if (!decision || tooLong || missingRequired) return;
setLoading(true); setLoading(true);
try { try {
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined); const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
@@ -79,10 +88,16 @@ export function DecisionModal({
title={m.title} title={m.title}
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" className="flex-1 sm:flex-none" onClick={onClose}>
Cancel Cancel
</Button> </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} {m.verb}
</Button> </Button>
</> </>
@@ -90,19 +105,22 @@ export function DecisionModal({
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-border bg-surface-raised/50 p-3"> <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"> <p className="mt-1 text-xs text-muted">
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "} {request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name}
{request.agent?.name}
</p> </p>
</div> </div>
<p className="text-sm text-muted">{m.blurb}</p> <p className="text-sm text-muted">{m.blurb}</p>
<div> <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"> <span className="text-sm text-muted">
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"} Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
</span> </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} {comment.length}/{MAX}
</span> </span>
</div> </div>
@@ -110,9 +128,11 @@ export function DecisionModal({
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
rows={4} 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…"} 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" 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 { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useNotifications } from "../context/NotificationsContext";
import { NotificationBell } from "./NotificationBell"; import { NotificationBell } from "./NotificationBell";
import { Sheet } from "./Sheet";
import {
AgentsIcon,
BellIcon,
HomeIcon,
LogOutIcon,
RequestsIcon,
SettingsIcon,
ShieldIcon,
} from "./icons";
const navItems = [ const navItems = [
{ to: "/", label: "Dashboard", end: true }, { to: "/", label: "Dashboard", end: true },
@@ -10,18 +21,27 @@ const navItems = [
{ to: "/settings", label: "Settings" }, { 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 }) { export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
return ( return (
<div className="min-h-screen"> <div className="flex min-h-[100dvh] flex-col">
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur"> <header
<div className="mx-auto flex max-w-6xl items-center justify-between gap-2 px-3 py-3 sm:px-4"> 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"> <div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link to="/" className="flex items-center gap-2"> <Link to="/" className="tap-sm flex items-center gap-2">
<span className="hidden text-xl xs:inline"></span> <img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" />
<span className="text-base font-bold tracking-tight sm:text-lg"> <span className="text-base font-bold tracking-tight sm:text-lg">
Patch<span className="text-primary">Pass</span> Patch<span className="text-primary">Pass</span>
</span> </span>
@@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) {
</div> </div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2"> <div className="flex shrink-0 items-center gap-1 sm:gap-2">
<NotificationBell /> {/* Below md the bell is replaced by the Alerts tab. */}
<div className="relative"> <div className="hidden md:block">
<button <NotificationBell />
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>
)}
</div> </div>
<AccountMenu />
</div> </div>
</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> </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"> <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"> <div className="flex flex-wrap items-center justify-center gap-x-3">
<Link to="/privacy" className="hover:text-muted"> <Link to="/privacy" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
Privacy Policy Privacy Policy
</Link> </Link>
<span>·</span> <span aria-hidden>·</span>
<Link to="/terms" className="hover:text-muted"> <Link to="/terms" className="inline-flex min-h-11 items-center hover:text-muted sm:min-h-0">
Terms of Service Terms of Service
</Link> </Link>
<span>·</span> <span className="hidden sm:inline" aria-hidden>
<span>PatchPass review changes, approve intent, let agents proceed.</span> ·
</span>
<span className="w-full sm:w-auto">
PatchPass review changes, approve intent, let agents proceed.
</span>
</div> </div>
</footer> </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> </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({ export function Modal({
open, open,
onClose, onClose,
@@ -17,43 +23,9 @@ export function Modal({
footer?: ReactNode; footer?: ReactNode;
wide?: boolean; 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 ( return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4"> <Sheet open={open} onClose={onClose} onSubmit={onSubmit} title={title} footer={footer} wide={wide}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> {children}
<div </Sheet>
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>
); );
} }
+5 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext"; import { useNotifications } from "../context/NotificationsContext";
import { BellIcon } from "./icons";
import { relativeTime } from "../utils"; import { relativeTime } from "../utils";
export function NotificationBell() { export function NotificationBell() {
@@ -21,25 +22,19 @@ export function NotificationBell() {
<div className="relative" ref={ref}> <div className="relative" ref={ref}>
<button <button
onClick={() => setOpen((o) => !o)} 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" aria-label="Notifications"
> >
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"> <BellIcon className="h-5 w-5" />
<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>
{unreadCount > 0 && ( {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} {unreadCount > 99 ? "99+" : unreadCount}
</span> </span>
)} )}
</button> </button>
{open && ( {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"> <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<span className="text-sm font-semibold">Notifications</span> <span className="text-sm font-semibold">Notifications</span>
<div className="flex gap-2 text-xs"> <div className="flex gap-2 text-xs">
+63
View File
@@ -0,0 +1,63 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
const FADE = 28; // px of taper at each overflowing edge
/**
* Horizontally scrollable row of chips/tabs. Fades whichever edge still has
* content behind it, so a clipped item reads as "scroll for more" rather than
* as a layout bug — and contains its overscroll so a sideways swipe never
* triggers the browser's back gesture.
*/
export function ScrollRow({
children,
className = "",
wrapperClassName = "",
bleed = true,
}: {
children: ReactNode;
/** Applied to the scrolling element (layout of the items). */
className?: string;
/** Applied to the positioned wrapper (borders, margins). */
wrapperClassName?: string;
/** Extend to the screen edges on phones so the row reads as scrollable. */
bleed?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [edges, setEdges] = useState({ start: false, end: false });
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
}, []);
useEffect(() => {
update();
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(update);
ro.observe(el);
for (const child of Array.from(el.children)) ro.observe(child);
return () => ro.disconnect();
}, [update]);
const mask = `linear-gradient(to right, ${
edges.start ? `transparent 0, black ${FADE}px` : "black 0"
}, ${edges.end ? `black calc(100% - ${FADE}px), transparent 100%` : "black 100%"})`;
return (
<div className={`relative ${bleed ? "-mx-3 sm:mx-0" : ""} ${wrapperClassName}`}>
<div
ref={ref}
onScroll={update}
style={{ maskImage: mask, WebkitMaskImage: mask }}
className={`no-scrollbar flex overflow-x-auto overscroll-x-contain ${
bleed ? "px-3 sm:px-0" : ""
} ${className}`}
>
{children}
</div>
</div>
);
}
+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 <button
{...props} {...props}
disabled={props.disabled || loading} 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" />} {loading && <Spinner className="h-4 w-4" />}
{children} {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 ( return (
<label className="block"> <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 <input
{...props} {...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> </label>
); );
} }
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) { export function Textarea({
label,
className = "",
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
return ( return (
<label className="block"> <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 <textarea
{...props} {...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> </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 ( return (
<button <button
type="button" type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!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 <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 <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> </span>
{label && <span className="text-sm text-text">{label}</span>} {label && <span className="text-left text-sm text-text">{label}</span>}
</button> </button>
); );
} }
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) { export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
return ( 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>} {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>} {subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
</div> </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 ( return (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4"> <div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4">
<div> <div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight text-text sm:text-2xl">{title}</h1> <h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>} {subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div> </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> </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) */ /* PatchPass design tokens (Tailwind v4 @theme) */
@theme { @theme {
/* Small-phone breakpoint (iPhone SE and friends sit below it). */
--breakpoint-xs: 24rem;
--color-bg: #0a0e17; --color-bg: #0a0e17;
--color-surface: #111726; --color-surface: #111726;
--color-surface-raised: #1a2234; --color-surface-raised: #1a2234;
@@ -24,6 +27,15 @@
--color-cancelled: #7a8296; --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, html,
body, body,
#root { #root {
@@ -35,21 +47,158 @@ body,
overflow-x: clip; overflow-x: clip;
} }
body { /* Landscape notch insets, applied once for all in-flow content. Fixed-position
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, chrome escapes this and re-applies the insets itself via `px-safe`. */
sans-serif; #root {
-webkit-font-smoothing: antialiased; padding-left: var(--safe-left);
padding-right: var(--safe-right);
} }
button:hover, html {
a:hover { /* Stop iOS from re-flowing type when the device rotates. */
cursor: pointer; -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; 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 syntax coloring */
.diff-add { .diff-add {
background-color: rgba(62, 207, 142, 0.13); background-color: rgba(62, 207, 142, 0.13);
@@ -66,6 +215,8 @@ a:hover {
color: var(--color-faint); color: var(--color-faint);
} }
/* ── Motion ─────────────────────────────────────────────────────────────────── */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
@@ -94,3 +245,86 @@ a:hover {
.animate-pulse-ring { .animate-pulse-ring {
animation: pulseRing 1.8s ease-out infinite; 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() { function FullScreenLoader() {
return ( 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" /> <Spinner className="h-8 w-8" />
</div> </div>
); );
@@ -66,7 +66,19 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<App /> <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> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
+117 -69
View File
@@ -4,6 +4,8 @@ import { admin } from "../api/client";
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types"; import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui"; import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
import { ConfirmSheet } from "../components/ConfirmSheet";
import { ScrollRow } from "../components/ScrollRow";
import { relativeTime } from "../utils"; import { relativeTime } from "../utils";
type Tab = "users" | "agents" | "settings" | "audit"; type Tab = "users" | "agents" | "settings" | "audit";
@@ -20,19 +22,19 @@ export function AdminPage() {
return ( return (
<div> <div>
<PageHeader title="Admin" subtitle="Platform administration." /> <PageHeader title="Admin" subtitle="Platform administration." />
<div className="mb-6 flex gap-1 overflow-x-auto border-b border-border"> <ScrollRow wrapperClassName="mb-6 border-b border-border" className="gap-1">
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
key={t.id} key={t.id}
onClick={() => setTab(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" tab === t.id ? "border-b-2 border-accent text-text" : "text-muted hover:text-text"
}`} }`}
> >
{t.label} {t.label}
</button> </button>
))} ))}
</div> </ScrollRow>
{tab === "users" && <UsersTab />} {tab === "users" && <UsersTab />}
{tab === "agents" && <AgentsTab />} {tab === "agents" && <AgentsTab />}
{tab === "settings" && <SettingsTab />} {tab === "settings" && <SettingsTab />}
@@ -45,6 +47,8 @@ function UsersTab() {
const { user: me } = useAuth(); const { user: me } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pendingDelete, setPendingDelete] = useState<AdminUser | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -75,13 +79,16 @@ function UsersTab() {
} }
}; };
const remove = async (u: AdminUser) => { const remove = async (u: AdminUser) => {
if (!confirm(`Delete user "${u.username}" and all their data?`)) return; setBusy(true);
try { try {
await admin.deleteUser(u.id); await admin.deleteUser(u.id);
toast.success("User deleted"); toast.success("User deleted");
setPendingDelete(null);
load(); load();
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Failed"); toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setBusy(false);
} }
}; };
@@ -89,41 +96,54 @@ function UsersTab() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{users.map((u) => ( {users.map((u) => (
<Card key={u.id} className="flex flex-wrap items-center gap-3 p-4"> <Card key={u.id} className="p-3.5 sm:p-4">
<div className="min-w-0 flex-1"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0 flex-1">
<span className="font-medium text-text">{u.display_name}</span> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs text-faint">@{u.username}</span> <span className="font-medium text-text">{u.display_name}</span>
{u.role === "ADMIN" && ( <span className="text-xs text-faint">@{u.username}</span>
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent"> {u.role === "ADMIN" && (
ADMIN <span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
</span> ADMIN
)} </span>
{u.disabled && ( )}
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected"> {u.disabled && (
DISABLED <span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
</span> 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> </div>
<p className="text-xs text-muted"> {u.id !== me?.id && (
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)} <div className="flex w-full gap-2 *:flex-1 sm:w-auto sm:flex-nowrap sm:*:flex-none">
</p> <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> </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> </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> </div>
); );
} }
@@ -131,6 +151,9 @@ function UsersTab() {
function AgentsTab() { function AgentsTab() {
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]); const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pendingDelete, setPendingDelete] = useState<Agent | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setAgents(await admin.agents()); setAgents(await admin.agents());
@@ -151,13 +174,16 @@ function AgentsTab() {
} }
}; };
const remove = async (a: Agent) => { const remove = async (a: Agent) => {
if (!confirm(`Delete agent "${a.name}"?`)) return; setBusy(true);
try { try {
await admin.deleteAgent(a.id); await admin.deleteAgent(a.id);
toast.success("Agent deleted"); toast.success("Agent deleted");
setPendingDelete(null);
load(); load();
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Failed"); toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setBusy(false);
} }
}; };
@@ -165,29 +191,42 @@ function AgentsTab() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{agents.map((a) => ( {agents.map((a) => (
<Card key={a.id} className="flex flex-wrap items-center gap-3 p-4"> <Card key={a.id} className="p-3.5 sm:p-4">
<div className="min-w-0 flex-1"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0 flex-1">
<span className="font-medium text-text">{a.name}</span> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs text-faint">by @{a.owner.username}</span> <span className="font-medium text-text">{a.name}</span>
{a.disabled && ( <span className="text-xs text-faint">by @{a.owner.username}</span>
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected"> {a.disabled && (
DISABLED <span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
</span> 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> </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> </div>
</Card> </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> </div>
); );
} }
@@ -195,7 +234,10 @@ function AgentsTab() {
function SettingsTab() { function SettingsTab() {
const [settings, setSettings] = useState<GlobalSettings | null>(null); const [settings, setSettings] = useState<GlobalSettings | null>(null);
useEffect(() => { useEffect(() => {
admin.settings().then(setSettings).catch(() => {}); admin
.settings()
.then(setSettings)
.catch(() => {});
}, []); }, []);
const update = async (patch: Partial<GlobalSettings>) => { const update = async (patch: Partial<GlobalSettings>) => {
@@ -211,15 +253,15 @@ function SettingsTab() {
if (!settings) return <Loader />; if (!settings) return <Loader />;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5"> <Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
<div> <div className="min-w-0">
<p className="font-medium text-text">Enable registration</p> <p className="font-medium text-text">Enable registration</p>
<p className="text-sm text-muted">Allow new humans to create accounts.</p> <p className="text-sm text-muted">Allow new humans to create accounts.</p>
</div> </div>
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} /> <Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
</Card> </Card>
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5"> <Card className="flex items-center justify-between gap-4 p-4 sm:p-5">
<div> <div className="min-w-0">
<p className="font-medium text-text">Enable requests</p> <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> <p className="text-sm text-muted">Allow agents to submit new change requests platform-wide.</p>
</div> </div>
@@ -251,14 +293,20 @@ function AuditTab() {
<div> <div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{logs.map((l) => ( {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"> <Card key={l.id} className="p-3 text-sm">
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code> <div className="flex flex-wrap items-center gap-x-2 gap-y-1 sm:flex-nowrap sm:gap-3">
<span className="min-w-0 flex-1 truncate text-muted"> <code className="shrink-0 rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">
{l.actor ? `@${l.actor.username}` : "system"} {l.action}
{l.target_type && `${l.target_type}:${l.target_id}`} </code>
{l.detail && ` · ${l.detail}`} <span className="min-w-0 flex-1 break-words text-muted sm:truncate">
</span> {l.actor ? `@${l.actor.username}` : "system"}
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">{relativeTime(l.created_at)}</span> {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> </Card>
))} ))}
</div> </div>
@@ -267,7 +315,7 @@ function AuditTab() {
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> <Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous Previous
</Button> </Button>
<span className="text-sm text-muted"> <span className="text-sm text-muted tabular-nums">
Page {page} of {totalPages} Page {page} of {totalPages}
</span> </span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}> <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 { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
import { AgentAvatar } from "../components/AgentAvatar"; import { AgentAvatar } from "../components/AgentAvatar";
import { AgentFormModal, ConnectModal } from "../components/AgentModals"; 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() { export function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@@ -13,6 +18,9 @@ export function AgentsPage() {
const [editing, setEditing] = useState<Agent | null>(null); const [editing, setEditing] = useState<Agent | null>(null);
const [connectAgent, setConnectAgent] = useState<Agent | null>(null); const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
const [revealedKey, setRevealedKey] = useState<string | 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 () => { const load = useCallback(async () => {
try { try {
@@ -35,15 +43,18 @@ export function AgentsPage() {
}; };
const regenerate = async (agent: Agent) => { const regenerate = async (agent: Agent) => {
if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return; setBusy(true);
try { try {
const updated = await agentsApi.regenerateKey(agent.id); const updated = await agentsApi.regenerateKey(agent.id);
toast.success("API key regenerated"); toast.success("API key regenerated");
setRevealedKey(updated.api_key ?? null); setRevealedKey(updated.api_key ?? null);
setConnectAgent(updated); setConnectAgent(updated);
setConfirm(null);
load(); load();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed"); toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setBusy(false);
} }
}; };
@@ -58,16 +69,24 @@ export function AgentsPage() {
}; };
const remove = async (agent: Agent) => { const remove = async (agent: Agent) => {
if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return; setBusy(true);
try { try {
await agentsApi.remove(agent.id); await agentsApi.remove(agent.id);
toast.success("Agent deleted"); toast.success("Agent deleted");
setConfirm(null);
load(); load();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed"); toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setBusy(false);
} }
}; };
const openEdit = (a: Agent) => {
setEditing(a);
setFormOpen(true);
};
return ( return (
<div> <div>
<PageHeader <PageHeader
@@ -97,47 +116,65 @@ export function AgentsPage() {
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API." 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) => ( {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"> <div className="flex items-start gap-3">
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} /> <AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-text">{a.name}</h3> <h3 className="truncate font-semibold text-text">{a.name}</h3>
{a.disabled && ( {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 DISABLED
</span> </span>
)} )}
</div> </div>
{a.description && <p className="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>} {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 ·{" "} {a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
<code>{a.api_key_masked}</code> <code>{a.api_key_masked}</code>
</p> </p>
</div> </div>
</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 Connect
</Button> </Button>
<Button variant="ghost" className="flex-1" onClick={() => openEdit(a)}>
Edit
</Button>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { className="w-11 shrink-0 px-0"
setEditing(a); aria-label={`More actions for ${a.name}`}
setFormOpen(true); 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 Edit
</Button> </Button>
<Button variant="ghost" onClick={() => regenerate(a)}> <Button variant="ghost" onClick={() => setConfirm({ agent: a, kind: "regenerate" })}>
Regenerate key Regenerate key
</Button> </Button>
<Button variant="ghost" onClick={() => toggleDisabled(a)}> <Button variant="ghost" onClick={() => toggleDisabled(a)}>
{a.disabled ? "Enable" : "Disable"} {a.disabled ? "Enable" : "Disable"}
</Button> </Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}> <Button
variant="ghost"
className="text-rejected"
onClick={() => setConfirm({ agent: a, kind: "delete" })}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -146,23 +183,106 @@ export function AgentsPage() {
</div> </div>
)} )}
<AgentFormModal <AgentFormModal open={formOpen} onClose={() => setFormOpen(false)} agent={editing} onSaved={onSaved} />
open={formOpen}
onClose={() => setFormOpen(false)} <ConnectModal
agent={editing} open={!!connectAgent}
onSaved={onSaved} onClose={() => {
setConnectAgent(null);
setRevealedKey(null);
}}
agent={connectAgent}
revealedKey={revealedKey}
/> />
{connectAgent && (
<ConnectModal {/* Overflow actions (mobile) */}
open={!!connectAgent} <Sheet
onClose={() => { open={!!menuAgent}
setConnectAgent(null); onClose={() => setMenuAgent(null)}
setRevealedKey(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> </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 { requests as requestsApi, agents as agentsApi } from "../api/client";
import type { Agent, ChangeRequest, RequestState } from "../api/types"; import type { Agent, ChangeRequest, RequestState } from "../api/types";
import { useNotifications } from "../context/NotificationsContext"; 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 { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar"; import { AgentAvatar } from "../components/AgentAvatar";
import { ChevronRightIcon } from "../components/icons";
import { expiresIn, relativeTime } from "../utils"; import { expiresIn, relativeTime } from "../utils";
const summaryTiles: { state: RequestState; label: string }[] = [ const summaryTiles: { state: RequestState; label: string }[] = [
@@ -56,20 +57,25 @@ export function DashboardPage() {
subtitle="Requests awaiting your review, and your connected agents." 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) => ( {summaryTiles.map((t) => (
<Card key={t.state} className="p-4"> <Card key={t.state} className="p-3.5 sm:p-4">
<p className="text-3xl font-bold text-text">{counts[t.state] ?? 0}</p> <p className="text-2xl font-bold text-text tabular-nums sm:text-3xl">
<p className="mt-1 text-xs text-muted">{t.label}</p> {counts[t.state] ?? 0}
</p>
<p className="mt-0.5 text-xs text-muted sm:mt-1">{t.label}</p>
</Card> </Card>
))} ))}
</div> </div>
<div className="grid gap-8 lg:grid-cols-3"> {/* min-w-0 on both tracks: grid items default to min-width:auto, so the
<div className="lg:col-span-2"> 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"> <div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Awaiting review</h2> <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 View all
</Link> </Link>
</div> </div>
@@ -80,7 +86,7 @@ export function DashboardPage() {
subtitle="No requests are waiting for your review right now." subtitle="No requests are waiting for your review right now."
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2.5">
{pending.map((r) => ( {pending.map((r) => (
<PendingRow key={r.request_id} request={r} /> <PendingRow key={r.request_id} request={r} />
))} ))}
@@ -88,15 +94,19 @@ export function DashboardPage() {
)} )}
</div> </div>
<div> <div className="min-w-0">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Agents</h2> <h2 className="text-lg font-semibold">Agents</h2>
<Link to="/agents" className="text-sm text-primary hover:underline"> <Link to="/agents" className={textLinkClass}>
Manage Manage
</Link> </Link>
</div> </div>
{agents.length === 0 ? ( {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"> <div className="space-y-2">
{agents.map((a) => ( {agents.map((a) => (
@@ -123,30 +133,38 @@ function PendingRow({ request }: { request: ChangeRequest }) {
const exp = expiresIn(request.expires_at); const exp = expiresIn(request.expires_at);
return ( return (
<Link to={`/requests/${request.request_id}`} className="block"> <Link to={`/requests/${request.request_id}`} className="block">
<Card className="p-4 transition hover:border-border-strong hover:bg-surface-raised/40"> <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">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> <AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
<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 flex-1">
<div className="min-w-0"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<div className="flex flex-wrap items-center gap-2"> <p className="min-w-0 break-words font-medium leading-snug text-text">{request.title}</p>
<p className="break-words font-medium text-text">{request.title}</p> {request.resubmitted && (
{request.resubmitted && ( <span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes"> UPDATED
UPDATED </span>
</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>
</div> </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} /> <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> </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> </Card>
</Link> </Link>
); );
+37 -32
View File
@@ -2,23 +2,28 @@ import { Link } from "react-router-dom";
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) { function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-12"> <div className="pt-safe pb-safe mx-auto max-w-3xl px-4">
<Link to="/" className="mb-6 inline-block text-sm text-muted hover:text-text"> <div className="py-10 sm:py-12">
Back <Link
</Link> to="/"
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1> 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"
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text"> >
{children} 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> </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> </div>
); );
} }
@@ -27,28 +32,28 @@ export function PrivacyPage() {
return ( return (
<LegalShell title="Privacy Policy"> <LegalShell title="Privacy Policy">
<p> <p>
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data the
the platform stores and the controls you have over it. platform stores and the controls you have over it.
</p> </p>
<h2>Data we store</h2> <h2>Data we store</h2>
<p> <p>
We store the account data you provide (username, display name, a bcrypt-hashed password, and an 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, 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 description, website, icon URL, and an API key), the change requests your agents submit, the decisions
decisions you make, and your in-app notifications. you make, and your in-app notifications.
</p> </p>
<h2>Agent icons</h2> <h2>Agent icons</h2>
<p> <p>
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image When you set an agent icon URL, the backend fetches it once to verify it points to a valid image (JPG,
(JPG, PNG, or GIF, under 1&nbsp;MB). The image itself is <strong>not</strong> stored or cached PNG, or GIF, under 1&nbsp;MB). The image itself is <strong>not</strong> stored or cached only the
only the URL you provided is kept. URL you provided is kept.
</p> </p>
<h2>How your data is used</h2> <h2>How your data is used</h2>
<p> <p>
Data is used solely to operate the approval workflow: routing agent requests to you for review, 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 recording decisions, and delivering notifications. We do not sell data or share it with third parties.
parties. Administrators of your instance can view platform data for moderation; access to another Administrators of your instance can view platform data for moderation; access to another user's
user's request payloads is explicitly audited. request payloads is explicitly audited.
</p> </p>
<h2>Retention</h2> <h2>Retention</h2>
<p> <p>
@@ -58,8 +63,8 @@ export function PrivacyPage() {
<h2>Your rights (GDPR)</h2> <h2>Your rights (GDPR)</h2>
<p> <p>
You can export all of your data as machine-readable JSON at any time from Settings. You can also 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, delete your account, which permanently removes your account and cascades to all your agents, change
change requests, and notifications. These actions are self-service and take effect immediately. requests, and notifications. These actions are self-service and take effect immediately.
</p> </p>
<h2>Security</h2> <h2>Security</h2>
<p> <p>
@@ -92,13 +97,13 @@ export function TermsPage() {
</p> </p>
<h2>Rate limits</h2> <h2>Rate limits</h2>
<p> <p>
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number To keep the platform usable, agents are limited to 15 requests per hour and a configurable number of
of simultaneous pending requests, and each human may own up to 5 agents. simultaneous pending requests, and each human may own up to 5 agents.
</p> </p>
<h2>Availability</h2> <h2>Availability</h2>
<p> <p>
This is self-hosted software. Availability, backups, and data durability are the responsibility of This is self-hosted software. Availability, backups, and data durability are the responsibility of the
the operator of this instance. operator of this instance.
</p> </p>
<h2>Changes</h2> <h2>Changes</h2>
<p>These terms may be updated by the operator of your instance.</p> <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" label="Username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
autoFocus autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required required
/> />
<Input <Input
@@ -52,6 +55,7 @@ export function LoginPage() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required required
/> />
{needsTotp && ( {needsTotp && (
@@ -61,6 +65,8 @@ export function LoginPage() {
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))} onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456" placeholder="123456"
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
autoFocus autoFocus
/> />
)} )}
@@ -90,10 +96,10 @@ export function AuthShell({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="flex min-h-[100dvh] items-center justify-center p-3 sm:p-4"> <div className="pt-safe pb-safe flex min-h-[100dvh] items-center justify-center">
<div className="w-full max-w-md"> <div className="w-full max-w-md px-3 py-6 sm:px-4">
<div className="mb-8 text-center"> <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"> <h1 className="text-3xl font-bold tracking-tight">
Patch<span className="text-primary">Pass</span> Patch<span className="text-primary">Pass</span>
</h1> </h1>
+14 -5
View File
@@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext"; import { useNotifications } from "../context/NotificationsContext";
import { Button, Card, EmptyState, PageHeader } from "../components/ui"; import { Button, Card, EmptyState, PageHeader } from "../components/ui";
import { ChevronRightIcon } from "../components/icons";
import { relativeTime } from "../utils"; import { relativeTime } from "../utils";
export function NotificationsPage() { export function NotificationsPage() {
@@ -33,7 +34,7 @@ export function NotificationsPage() {
{items.map((n) => ( {items.map((n) => (
<Card <Card
key={n.id} 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" : "" n.read ? "opacity-60" : ""
}`} }`}
onClick={() => { onClick={() => {
@@ -41,12 +42,20 @@ export function NotificationsPage() {
if (n.request_id) navigate(`/requests/${n.request_id}`); 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"> <div className="min-w-0 flex-1">
<p className="font-medium text-text">{n.title}</p> <p className="font-medium leading-snug text-text">{n.title}</p>
<p className="text-sm text-muted">{n.message}</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> </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> </Card>
))} ))}
</div> </div>
+7 -1
View File
@@ -55,19 +55,24 @@ export function RegisterPage() {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive." hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
autoFocus autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required required
/> />
<Input <Input
label="Display name (optional)" label="Display name (optional)"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
autoComplete="name"
/> />
<Input <Input
label="Password" label="Password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required required
/> />
<Input <Input
@@ -75,6 +80,7 @@ export function RegisterPage() {
type="password" type="password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required required
/> />
<Button type="submit" loading={loading} className="w-full"> <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 { ChangeList } from "../components/ChangeRenderer";
import { AgentAvatar } from "../components/AgentAvatar"; import { AgentAvatar } from "../components/AgentAvatar";
import { DecisionModal } from "../components/DecisionModal"; import { DecisionModal } from "../components/DecisionModal";
import { ChevronLeftIcon } from "../components/icons";
import { expiresIn, formatDateTime, relativeTime } from "../utils"; import { expiresIn, formatDateTime, relativeTime } from "../utils";
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES"; type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
@@ -58,146 +59,195 @@ export function RequestDetailPage() {
const canDecide = request.state === "PENDING"; const canDecide = request.state === "PENDING";
return ( return (
<div className="animate-fade-in"> <div>
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text"> {/* The fade-in wrapper is kept inside: its transform would otherwise make
Back to requests it the containing block for the fixed decision bar below. */}
</Link> <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" && ( {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"> <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 rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes"> <span className="animate-pulse-ring w-fit rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
UPDATED UPDATED
</span> </span>
<p className="text-sm text-text"> <p className="text-sm text-text">
This request was revised by the agent after you requested changes (update #{request.update_count}). This request was revised by the agent after you requested changes (update #
Please re-review the changes below. {request.update_count}). Please re-review the changes below.
</p> </p>
</div> </div>
)} )}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<div className="order-2 lg:order-1 lg:col-span-2"> <div className="min-w-0 lg:col-span-2">
<div className="mb-4 flex items-start justify-between gap-4"> <div className="mb-4">
<div> <div className="flex flex-wrap items-start gap-x-3 gap-y-2">
<div className="flex flex-wrap items-center gap-2"> <h1 className="min-w-0 break-words text-xl font-bold leading-tight tracking-tight sm:text-2xl">
<h1 className="break-words text-xl font-bold tracking-tight sm:text-2xl">{request.title}</h1> {request.title}
<StateBadge state={request.state} /> </h1>
<StateBadge state={request.state} className="mt-0.5 shrink-0" />
</div> </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> </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>
{request.comment && ( <div className="min-w-0 space-y-4">
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4"> {/* On phones this lives in the sticky bar at the bottom instead. */}
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint"> {canDecide && (
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"} <Card className="hidden space-y-2 p-4 lg:block">
</p> <p className="mb-1 text-sm font-semibold">Your decision</p>
<p className="text-sm text-text">{request.comment}</p> <Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
</div> Approve
)} </Button>
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint"> Request changes
Proposed changes ({request.changes.length}) </Button>
</h2> <Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
<ChangeList changes={request.changes} /> Reject
</div> </Button>
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
<div className="order-1 space-y-4 lg:order-2"> {exp.text}
{canDecide && ( </p>
<Card className="space-y-2 p-4"> </Card>
<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>
)} )}
{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"> <Card className="p-4">
<p className="mb-2 text-sm font-semibold">Metadata</p> <p className="mb-3 text-sm font-semibold">Agent</p>
<div className="space-y-1 text-xs"> {request.agent ? (
{Object.entries(request.metadata).map(([k, v]) => ( <div className="flex items-center gap-3">
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} /> <AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
))} <div className="min-w-0">
</div> <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> </Card>
)}
{request.receipt && ( <Card className="space-y-2 p-4 text-xs">
<Card className="border-approved/30 bg-approved/5 p-4"> <Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved"> <Row
<span>🔏</span> Signed receipt label="Content hash"
</p> value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
<div className="space-y-1 text-xs"> />
<Row label="Decision" value={request.receipt.payload.decision} /> <Row label="Created" value={relativeTime(request.created_at)} />
<Row label="Algorithm" value={request.receipt.algorithm} /> <Row label="Expires" value={formatDateTime(request.expires_at)} />
<Row {request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
label="Signature" {request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>} {request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
/>
</div>
</Card> </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>
</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> </div>
); );
} }
function Row({ label, value }: { label: string; value: React.ReactNode }) { function Row({ label, value }: { label: string; value: React.ReactNode }) {
return ( 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="shrink-0 text-faint">{label}</span>
<span className="break-all text-text sm:text-right">{value}</span> <span className="break-all text-text sm:text-right">{value}</span>
</div> </div>
+51 -40
View File
@@ -6,6 +6,8 @@ import { useNotifications } from "../context/NotificationsContext";
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui"; import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
import { StateBadge } from "../components/StateBadge"; import { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar"; import { AgentAvatar } from "../components/AgentAvatar";
import { ChevronRightIcon } from "../components/icons";
import { ScrollRow } from "../components/ScrollRow";
import { relativeTime } from "../utils"; import { relativeTime } from "../utils";
const STATES: RequestState[] = [ const STATES: RequestState[] = [
@@ -31,7 +33,10 @@ export function RequestsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
agentsApi.list().then(setAgents).catch(() => {}); agentsApi
.list()
.then(setAgents)
.catch(() => {});
}, []); }, []);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -56,54 +61,52 @@ export function RequestsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const chip = (active: boolean) =>
`tap-sm min-h-9 shrink-0 rounded-full border px-3.5 text-sm font-medium transition ${
active
? "border-primary/40 bg-primary/15 text-primary"
: "border-border-strong bg-surface text-muted hover:text-text"
}`;
return ( return (
<div> <div>
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." /> <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"> <div className="mb-5 space-y-3.5">
<select {/* No scroll-snap: it clamps the resting scrollLeft to the container's
value={stateFilter} padding, which eats the left gutter, and flick-snapping through
onChange={(e) => { short chips feels wrong anyway. */}
setStateFilter(e.target.value as RequestState | ""); <ScrollRow className="gap-2 pb-0.5 sm:flex-wrap">
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) => (
<button <button
key={s}
onClick={() => { onClick={() => {
setStateFilter(s); setStateFilter("");
setPage(1); 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> </button>
))} {STATES.map((s) => (
</div> <button
key={s}
onClick={() => {
setStateFilter(s);
setPage(1);
}}
className={chip(stateFilter === s)}
>
{s.replace("_", " ").toLowerCase()}
</button>
))}
</ScrollRow>
<select <select
value={agentFilter} value={agentFilter}
onChange={(e) => { onChange={(e) => {
setAgentFilter(e.target.value ? Number(e.target.value) : ""); setAgentFilter(e.target.value ? Number(e.target.value) : "");
setPage(1); 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> <option value="">All agents</option>
{agents.map((a) => ( {agents.map((a) => (
@@ -124,25 +127,33 @@ export function RequestsPage() {
<div className="space-y-2"> <div className="space-y-2">
{items.map((r) => ( {items.map((r) => (
<Link key={r.request_id} to={`/requests/${r.request_id}`} className="block"> <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"> <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">
<div className="flex min-w-0 items-start gap-3">
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} /> <AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="break-words font-medium text-text">{r.title}</p> <p className="min-w-0 break-words font-medium leading-snug text-text">
{r.title}
</p>
{r.resubmitted && r.state === "PENDING" && ( {r.resubmitted && r.state === "PENDING" && (
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes"> <span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
UPDATED UPDATED
</span> </span>
)} )}
</div> </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"} ·{" "} {r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
{relativeTime(r.created_at)} {relativeTime(r.created_at)}
</p> </p>
<div className="mt-2 sm:hidden">
<StateBadge state={r.state} />
</div>
</div> </div>
<div className="hidden shrink-0 sm:block">
<StateBadge state={r.state} />
</div> </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> </Card>
</Link> </Link>
))} ))}
@@ -154,7 +165,7 @@ export function RequestsPage() {
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> <Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous Previous
</Button> </Button>
<span className="text-sm text-muted"> <span className="text-sm text-muted tabular-nums">
Page {page} of {totalPages} Page {page} of {totalPages}
</span> </span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}> <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 }) { function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return ( 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> <h2 className="text-base font-semibold text-text">{title}</h2>
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>} {description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
<div className={description ? "" : "mt-4"}>{children}</div> <div className={description ? "" : "mt-4"}>{children}</div>
@@ -118,9 +118,11 @@ export function SettingsPage() {
<div className="grid gap-5 lg:grid-cols-2"> <div className="grid gap-5 lg:grid-cols-2">
<Section title="Profile"> <Section title="Profile">
<div className="space-y-3"> <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)} /> <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> </div>
</Section> </Section>
@@ -129,17 +131,23 @@ export function SettingsPage() {
<Input <Input
label="Current password" label="Current password"
type="password" type="password"
autoComplete="current-password"
value={curPw} value={curPw}
onChange={(e) => setCurPw(e.target.value)} onChange={(e) => setCurPw(e.target.value)}
/> />
<Input <Input
label="New password" label="New password"
type="password" type="password"
autoComplete="new-password"
value={newPw} value={newPw}
onChange={(e) => setNewPw(e.target.value)} onChange={(e) => setNewPw(e.target.value)}
hint="At least 8 characters." 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 Change password
</Button> </Button>
</div> </div>
@@ -165,8 +173,10 @@ export function SettingsPage() {
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))} onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456" placeholder="123456"
inputMode="numeric" 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}> <Button onClick={confirm2fa} disabled={totp.length !== 6}>
Enable 2FA Enable 2FA
</Button> </Button>
@@ -198,9 +208,11 @@ export function SettingsPage() {
max={90} max={90}
value={autoDeleteDays} value={autoDeleteDays}
onChange={(e) => setAutoDeleteDays(Number(e.target.value))} onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
onMouseUp={() => saveAutoDelete(true, autoDeleteDays)} // pointerup covers mouse, touch and pen in one handler.
onTouchEnd={() => saveAutoDelete(true, autoDeleteDays)} onPointerUp={() => saveAutoDelete(true, autoDeleteDays)}
onKeyUp={() => saveAutoDelete(true, autoDeleteDays)}
className="w-full accent-[var(--color-primary)]" className="w-full accent-[var(--color-primary)]"
aria-label="Retention window in days"
/> />
</div> </div>
)} )}
@@ -208,13 +220,15 @@ export function SettingsPage() {
</Section> </Section>
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON."> <Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
<a href={account.exportUrl} download> <a href={account.exportUrl} download className="block sm:inline-block">
<Button variant="secondary">Download export</Button> <Button variant="secondary" className="w-full sm:w-auto">
Download export
</Button>
</a> </a>
</Section> </Section>
<Section title="Danger zone" description="Permanently delete your account and all associated data. This cannot be undone."> <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 Delete account
</Button> </Button>
</Section> </Section>
@@ -227,10 +241,10 @@ export function SettingsPage() {
title="Disable 2FA" title="Disable 2FA"
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setDisable2faOpen(false)}> <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDisable2faOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button variant="danger" onClick={disable2fa}> <Button variant="danger" className="flex-1 sm:flex-none" onClick={disable2fa}>
Disable Disable
</Button> </Button>
</> </>
@@ -239,6 +253,7 @@ export function SettingsPage() {
<Input <Input
label="Confirm your password" label="Confirm your password"
type="password" type="password"
autoComplete="current-password"
value={disablePw} value={disablePw}
onChange={(e) => setDisablePw(e.target.value)} onChange={(e) => setDisablePw(e.target.value)}
/> />
@@ -251,10 +266,15 @@ export function SettingsPage() {
title="Delete account" title="Delete account"
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setDeleteOpen(false)}> <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => setDeleteOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button variant="danger" onClick={deleteAccount} disabled={!deletePw}> <Button
variant="danger"
className="flex-1 sm:flex-none"
onClick={deleteAccount}
disabled={!deletePw}
>
Permanently delete Permanently delete
</Button> </Button>
</> </>
@@ -268,6 +288,7 @@ export function SettingsPage() {
<Input <Input
label="Confirm your password" label="Confirm your password"
type="password" type="password"
autoComplete="current-password"
value={deletePw} value={deletePw}
onChange={(e) => setDeletePw(e.target.value)} 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: