feat(ui): improve full mobile browser support
Implements responsive mobile layout improvements and host-only cookies for direct IP deployments. Co-authored-by: luna <clawy@reversed.dev> Co-committed-by: luna <clawy@reversed.dev>
This commit was merged in pull request #3.
This commit is contained in:
@@ -70,8 +70,11 @@ export function normalizeUsername(username: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the cookie domain scope from the configured DOMAIN. */
|
/** Compute the cookie domain scope from the configured DOMAIN. */
|
||||||
export function cookieDomain(domain: string): string {
|
export function cookieDomain(domain: string): string | undefined {
|
||||||
if (domain === "localhost") return "localhost";
|
// IP addresses cannot have a registrable parent domain. Treating the first
|
||||||
|
// octet as a subdomain would turn 100.74.255.106 into 74.255.106 and make
|
||||||
|
// browser sessions unusable on direct-IP test/self-hosted deployments.
|
||||||
|
if (domain === "localhost" || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(domain)) return undefined;
|
||||||
if (domain.split(".").length > 2) {
|
if (domain.split(".").length > 2) {
|
||||||
// Subdomain deployment: scope to the registrable parent domain.
|
// Subdomain deployment: scope to the registrable parent domain.
|
||||||
return domain.split(".").slice(1).join(".");
|
return domain.split(".").slice(1).join(".");
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import { createLogger } from "../../lib/logger";
|
|||||||
const log = createLogger("auth");
|
const log = createLogger("auth");
|
||||||
|
|
||||||
function setSessionCookie(ctr: any, hash: string) {
|
function setSessionCookie(ctr: any, hash: string) {
|
||||||
|
const domain = cookieDomain(DOMAIN);
|
||||||
ctr.cookies.set(
|
ctr.cookies.set(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
new Cookie(hash, {
|
new Cookie(hash, {
|
||||||
domain: cookieDomain(DOMAIN),
|
...(domain ? { domain } : {}),
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
|
|||||||
@@ -210,12 +210,12 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-border">
|
<div className="flex gap-1 overflow-x-auto border-b border-border">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
className={`px-3 py-2 text-sm font-medium transition ${
|
className={`shrink-0 px-3 py-2 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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,18 +59,18 @@ function typeChip(label: string, color: string) {
|
|||||||
export function ChangeCard({ change, index }: { change: Change; index: number }) {
|
export function ChangeCard({ change, index }: { change: Change; index: number }) {
|
||||||
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-4">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-mono text-faint">#{index + 1}</span>
|
<span className="text-xs font-mono text-faint">#{index + 1}</span>
|
||||||
{change.type === "unified_diff" && (
|
{change.type === "unified_diff" && (
|
||||||
<>
|
<>
|
||||||
{typeChip("diff", "bg-primary-dim/40 text-primary")}
|
{typeChip("diff", "bg-primary-dim/40 text-primary")}
|
||||||
<code className="text-sm text-text">{change.path}</code>
|
<code className="break-all text-sm text-text">{change.path}</code>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{change.type === "config" && (
|
{change.type === "config" && (
|
||||||
<>
|
<>
|
||||||
{typeChip("config", "bg-accent/15 text-accent")}
|
{typeChip("config", "bg-accent/15 text-accent")}
|
||||||
<code className="text-sm text-text">{change.path}</code>
|
<code className="break-all text-sm text-text">{change.path}</code>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{change.type === "custom" && (
|
{change.type === "custom" && (
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
|
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-2 px-3 py-3 sm:px-4">
|
||||||
<div className="flex items-center 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="flex items-center gap-2">
|
||||||
<span className="text-xl">✅</span>
|
<span className="hidden text-xl xs:inline">✅</span>
|
||||||
<span className="text-lg font-bold tracking-tight">
|
<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>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -56,7 +56,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -87,6 +87,15 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
Notifications
|
Notifications
|
||||||
</Link>
|
</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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -102,14 +111,14 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* mobile nav */}
|
{/* mobile nav */}
|
||||||
<nav className="flex items-center gap-1 overflow-x-auto border-t border-border px-4 py-2 md:hidden">
|
<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) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium ${
|
`whitespace-nowrap rounded-lg px-2 py-1.5 text-sm font-medium ${
|
||||||
isActive ? "bg-surface-raised text-text" : "text-muted"
|
isActive ? "bg-surface-raised text-text" : "text-muted"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@@ -117,15 +126,10 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
{user?.role === "ADMIN" && (
|
|
||||||
<NavLink to="/admin" className="whitespace-nowrap rounded-lg px-3 py-1.5 text-sm text-accent">
|
|
||||||
Admin
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
|
<main className="mx-auto max-w-6xl 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-3">
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export function Modal({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
<div
|
<div
|
||||||
className={`animate-fade-in relative z-10 w-full ${wide ? "max-w-3xl" : "max-w-lg"} rounded-2xl border border-border-strong bg-surface shadow-2xl`}
|
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">
|
<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>
|
<h2 className="text-lg font-semibold text-text">{title}</h2>
|
||||||
@@ -51,8 +51,8 @@ export function Modal({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{children}</div>
|
||||||
{footer && <div className="flex justify-end gap-2 border-t border-border px-5 py-4">{footer}</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function NotificationBell() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<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="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="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">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function Button({
|
|||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
disabled={props.disabled || loading}
|
disabled={props.disabled || loading}
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${className}`}
|
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}`}
|
||||||
>
|
>
|
||||||
{loading && <Spinner className="h-4 w-4" />}
|
{loading && <Spinner className="h-4 w-4" />}
|
||||||
{children}
|
{children}
|
||||||
@@ -110,12 +110,12 @@ export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?
|
|||||||
|
|
||||||
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
|
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
<div className="mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-6 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
|
<h1 className="text-xl font-bold tracking-tight text-text sm:text-2xl">{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 gap-2">{actions}</div>}
|
{actions && <div className="flex w-full gap-2 sm:w-auto">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ body,
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ 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 border-b border-border">
|
<div className="mb-6 flex gap-1 overflow-x-auto border-b border-border">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
className={`px-4 py-2 text-sm font-medium transition ${
|
className={`shrink-0 px-4 py-2 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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -110,7 +110,7 @@ function UsersTab() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{u.id !== me?.id && (
|
{u.id !== me?.id && (
|
||||||
<div className="flex gap-2">
|
<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")}>
|
<Button variant="ghost" onClick={() => setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
|
||||||
{u.role === "ADMIN" ? "Demote" : "Promote"}
|
{u.role === "ADMIN" ? "Demote" : "Promote"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -178,7 +178,7 @@ function AgentsTab() {
|
|||||||
</div>
|
</div>
|
||||||
{a.description && <p className="text-xs text-muted">{a.description}</p>}
|
{a.description && <p className="text-xs text-muted">{a.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
||||||
<Button variant="ghost" onClick={() => toggle(a)}>
|
<Button variant="ghost" onClick={() => toggle(a)}>
|
||||||
{a.disabled ? "Enable" : "Disable"}
|
{a.disabled ? "Enable" : "Disable"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -211,14 +211,14 @@ 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 items-center justify-between p-5">
|
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
||||||
<div>
|
<div>
|
||||||
<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 items-center justify-between p-5">
|
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
||||||
<div>
|
<div>
|
||||||
<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>
|
||||||
@@ -251,14 +251,14 @@ 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 items-center gap-3 p-3 text-sm">
|
<Card key={l.id} className="flex flex-wrap items-center gap-2 p-3 text-sm sm:flex-nowrap sm:gap-3">
|
||||||
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
|
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
|
||||||
<span className="min-w-0 flex-1 truncate text-muted">
|
<span className="min-w-0 flex-1 truncate text-muted">
|
||||||
{l.actor ? `@${l.actor.username}` : "system"}
|
{l.actor ? `@${l.actor.username}` : "system"}
|
||||||
{l.target_type && ` → ${l.target_type}:${l.target_id}`}
|
{l.target_type && ` → ${l.target_type}:${l.target_id}`}
|
||||||
{l.detail && ` · ${l.detail}`}
|
{l.detail && ` · ${l.detail}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-xs text-faint">{relativeTime(l.created_at)}</span>
|
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">{relativeTime(l.created_at)}</span>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-3">
|
<div className="grid gap-8 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<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="text-sm text-primary hover:underline">
|
||||||
View all →
|
View all →
|
||||||
@@ -89,7 +89,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<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="text-sm text-primary hover:underline">
|
||||||
Manage →
|
Manage →
|
||||||
@@ -124,12 +124,12 @@ function PendingRow({ request }: { request: ChangeRequest }) {
|
|||||||
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="p-4 transition hover:border-border-strong hover:bg-surface-raised/40">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
|
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="truncate font-medium 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
|
||||||
@@ -142,7 +142,7 @@ function PendingRow({ request }: { request: ChangeRequest }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
<div className="flex self-start sm:shrink-0 sm:flex-col sm:items-end sm:gap-1">
|
||||||
<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>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function AuthShell({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-4">
|
<div className="flex min-h-[100dvh] items-center justify-center p-3 sm:p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<div className="mb-3 text-4xl">✅</div>
|
<div className="mb-3 text-4xl">✅</div>
|
||||||
@@ -99,7 +99,7 @@ export function AuthShell({
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted">{subtitle}</p>
|
<p className="mt-2 text-sm text-muted">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<Card className="p-6">
|
<Card className="p-4 sm:p-6">
|
||||||
<h2 className="mb-5 text-lg font-semibold">{title}</h2>
|
<h2 className="mb-5 text-lg font-semibold">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ export function RequestDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="order-2 lg:order-1 lg:col-span-2">
|
||||||
<div className="mb-4 flex items-start justify-between gap-4">
|
<div className="mb-4 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{request.title}</h1>
|
<h1 className="break-words text-xl font-bold tracking-tight sm:text-2xl">{request.title}</h1>
|
||||||
<StateBadge state={request.state} />
|
<StateBadge state={request.state} />
|
||||||
</div>
|
</div>
|
||||||
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
|
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
|
||||||
@@ -102,7 +102,7 @@ export function RequestDetailPage() {
|
|||||||
<ChangeList changes={request.changes} />
|
<ChangeList changes={request.changes} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="order-1 space-y-4 lg:order-2">
|
||||||
{canDecide && (
|
{canDecide && (
|
||||||
<Card className="space-y-2 p-4">
|
<Card className="space-y-2 p-4">
|
||||||
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
||||||
@@ -197,9 +197,9 @@ export function RequestDetailPage() {
|
|||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex flex-col gap-1 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="text-right text-text">{value}</span>
|
<span className="break-all text-text sm:text-right">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,27 @@ export function RequestsPage() {
|
|||||||
<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-wrap items-center gap-2">
|
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<select
|
||||||
|
value={stateFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStateFilter(e.target.value as RequestState | "");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none sm:hidden"
|
||||||
|
>
|
||||||
|
<option value="">All states</option>
|
||||||
|
{STATES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s.replace("_", " ").toLowerCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="hidden sm:contents">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStateFilter("");
|
setStateFilter("");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className={`rounded-lg px-3 py-1.5 text-sm ${stateFilter === "" ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
className={`shrink-0 rounded-lg px-3 py-2 text-sm ${stateFilter === "" ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
@@ -77,18 +91,19 @@ export function RequestsPage() {
|
|||||||
setStateFilter(s);
|
setStateFilter(s);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className={`rounded-lg px-3 py-1.5 text-sm ${stateFilter === s ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
className={`shrink-0 rounded-lg px-3 py-2 text-sm ${stateFilter === s ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
||||||
>
|
>
|
||||||
{s.replace("_", " ").toLowerCase()}
|
{s.replace("_", " ").toLowerCase()}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
<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="ml-auto rounded-lg border border-border-strong bg-bg px-3 py-1.5 text-sm text-text outline-none"
|
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"
|
||||||
>
|
>
|
||||||
<option value="">All agents</option>
|
<option value="">All agents</option>
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
@@ -109,11 +124,12 @@ 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 items-center gap-3 p-3.5 transition hover:border-border-strong hover:bg-surface-raised/40">
|
<Card className="flex flex-col gap-2 p-3.5 transition hover:border-border-strong hover:bg-surface-raised/40 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<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 items-center gap-2">
|
||||||
<p className="truncate font-medium text-text">{r.title}</p>
|
<p className="break-words font-medium 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
|
||||||
@@ -125,7 +141,8 @@ export function RequestsPage() {
|
|||||||
{relativeTime(r.created_at)}
|
{relativeTime(r.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StateBadge state={r.state} />
|
</div>
|
||||||
|
<div className="self-start sm:shrink-0"><StateBadge state={r.state} /></div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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-5">
|
<Card className="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>
|
||||||
@@ -147,7 +147,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
<Section title="Two-factor authentication" description="Add a TOTP authenticator app for extra security.">
|
<Section title="Two-factor authentication" description="Add a TOTP authenticator app for extra security.">
|
||||||
{user?.totp_enabled ? (
|
{user?.totp_enabled ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span className="text-sm text-approved">✓ 2FA is enabled</span>
|
<span className="text-sm text-approved">✓ 2FA is enabled</span>
|
||||||
<Button variant="danger" onClick={() => setDisable2faOpen(true)}>
|
<Button variant="danger" onClick={() => setDisable2faOpen(true)}>
|
||||||
Disable
|
Disable
|
||||||
|
|||||||
Reference in New Issue
Block a user