Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/web/src/components/app/browser-extension-connections-card.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
import { MonitorSmartphone, Unplug } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { formatDateTime, formatRelativeTime } from "@/lib/format";

import type { BrowserExtensionConnection } from "./use-extension-pairing";

const initiallyVisibleConnections = 5;

type BrowserExtensionConnectionsCardProps = {
connections: BrowserExtensionConnection[];
isPending: boolean;
isError: boolean;
onRetry: () => void;
showAllConnections: boolean;
setShowAllConnections: Dispatch<SetStateAction<boolean>>;
revokeIsPending: boolean;
revokeIsError: boolean;
onRevoke: (connectionId: string) => void;
};

export function BrowserExtensionConnectionsCard({
connections,
isPending,
isError,
onRetry,
showAllConnections,
setShowAllConnections,
revokeIsPending,
revokeIsError,
onRevoke,
}: BrowserExtensionConnectionsCardProps): JSX.Element {
const visibleConnections = showAllConnections
? connections
: connections.slice(0, initiallyVisibleConnections);
const hiddenConnectionCount = connections.length - visibleConnections.length;

return (
<Card>
<CardHeader>
<CardTitle className="text-base">Paired browsers</CardTitle>
<CardDescription>
Each browser has its own access. Revoking one does not disconnect the
others.
</CardDescription>
</CardHeader>
<CardContent>
{isPending ? (
<p className="text-sm text-muted-foreground" role="status">
Loading paired browsers...
</p>
) : isError ? (
<div className="space-y-2">
<p className="text-sm text-destructive" role="alert">
Could not load paired browsers.
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="border border-border"
onClick={onRetry}
>
Try again
</Button>
</div>
) : connections.length === 0 ? (
<p className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
Waiting for this browser to finish connecting.
</p>
) : (
<div className="space-y-2" data-testid="paired-browser-list">
{visibleConnections.map((connection) => (
<div
key={connection.id}
className="flex items-center gap-3 rounded-md border border-border bg-background/50 px-3 py-3"
data-testid={`paired-browser-${connection.id}`}
>
<MonitorSmartphone
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{connection.deviceName}
</p>
<p
className="mt-0.5 text-xs text-muted-foreground"
title={`Paired ${formatDateTime(connection.createdAt)}; expires ${formatDateTime(connection.expiresAt)}`}
>
{connection.lastUsedAt
? `Last used ${formatRelativeTime(connection.lastUsedAt)}`
: `Paired ${formatRelativeTime(connection.createdAt)}`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 gap-1.5 text-destructive hover:text-destructive"
aria-label={`Revoke ${connection.deviceName}`}
disabled={revokeIsPending}
onClick={() => onRevoke(connection.id)}
>
<Unplug className="h-3.5 w-3.5" aria-hidden="true" />
Revoke
</Button>
</div>
))}
{connections.length > initiallyVisibleConnections && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => setShowAllConnections((visible) => !visible)}
>
{showAllConnections
? "Show fewer browsers"
: `Show ${hiddenConnectionCount} more ${hiddenConnectionCount === 1 ? "browser" : "browsers"}`}
</Button>
)}
</div>
)}
{revokeIsError && (
<p className="mt-2 text-sm text-destructive" role="alert">
Could not revoke that browser. Try again.
</p>
)}
</CardContent>
</Card>
);
}
147 changes: 147 additions & 0 deletions apps/web/src/components/app/browser-extension-pairing-card.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import { ShieldCheck } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";

import type { ApprovalState } from "./use-extension-pairing";

type BrowserExtensionPairingCardProps = {
approvalState: ApprovalState;
pairingRequestIsValid: boolean;
code: string | null;
error: string;
onApprove: () => void;
onCheckAgain: () => void;
};

export function BrowserExtensionPairingCard({
approvalState,
pairingRequestIsValid,
code,
error,
onApprove,
onCheckAgain,
}: BrowserExtensionPairingCardProps): JSX.Element {
return (
<Card className="border-primary/30">
<CardHeader className="flex-row items-start gap-3 space-y-0 pb-3">
<div className="rounded-lg bg-primary/10 p-2 text-primary">
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
</div>
<div className="space-y-1.5">
<CardTitle className="text-base">
{approvalState === "connected"
? "Browser connected"
: approvalState === "timedOut"
? "Connection still pending"
: "Approve this browser"}
</CardTitle>
<CardDescription>
{approvalState === "connected"
? "The extension is ready to send feedback to your agents."
: approvalState === "timedOut"
? "Dispatch has not seen the browser finish connecting yet."
: "Finish the connection request you started in the extension."}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-border bg-background/50 p-4">
{approvalState === "connected" ? (
<div className="flex items-start gap-3" role="status">
<ShieldCheck
className="mt-0.5 h-5 w-5 shrink-0 text-status-working"
aria-hidden="true"
/>
<div>
<p className="text-sm font-medium">
Browser extension connected
</p>
<p className="mt-1 text-sm text-muted-foreground">
You can close this page and return to the Dispatch extension.
</p>
</div>
</div>
) : !pairingRequestIsValid ? (
<p className="text-sm text-destructive" role="alert">
This browser extension pairing link is incomplete. Return to the
extension and start the connection again.
</p>
) : approvalState === "waiting" ? (
<div className="flex items-start gap-3" role="status">
<ShieldCheck
className="mt-0.5 h-5 w-5 shrink-0 text-status-working"
aria-hidden="true"
/>
<div>
<p className="text-sm font-medium">Connection approved</p>
<p className="mt-1 text-sm text-muted-foreground">
Return to the extension to finish connecting. This page will
update when the browser appears below.
</p>
</div>
</div>
) : approvalState === "timedOut" ? (
<div className="space-y-3" role="alert">
<div>
<p className="text-sm font-medium">
Browser has not finished connecting
</p>
<p className="mt-1 text-sm text-muted-foreground">
Keep the extension open while it finishes the exchange, then
check again. If the request is no longer visible in the
extension, start a new connection there.
</p>
</div>
<Button type="button" variant="primary" onClick={onCheckAgain}>
Check again
</Button>
</div>
) : (
<div className="space-y-4">
<div>
<p className="text-sm font-medium">
Chrome is requesting permission to connect
</p>
<p className="mt-1 text-sm text-muted-foreground">
Approve only if you started this request. The extension will
be able to view available agents and send page feedback to the
agent you select.
</p>
</div>
<div className="rounded-lg border border-primary/30 bg-primary/10 px-4 py-3 text-center">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Confirm this code matches the extension
</p>
<code className="mt-1 block text-xl font-semibold tracking-[0.18em] text-foreground">
{code}
</code>
</div>
{approvalState === "error" && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<Button
type="button"
variant="primary"
onClick={onApprove}
disabled={approvalState === "approving"}
>
{approvalState === "approving"
? "Connecting..."
: "Approve connection"}
</Button>
</div>
)}
</div>
</CardContent>
</Card>
);
}
Loading
Loading