1
0
Fork 0

refactor(impersonation): replace page control with modal

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-01 17:17:54 -04:00
parent 6efe23a9e4
commit 7624327ab9
2 changed files with 127 additions and 55 deletions

View file

@ -1,55 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type Target = { id: number | string; username?: string | null; displayName?: string | null };
export function ImpersonationControl({ users }: { users: Target[] }) {
const router = useRouter();
const [userId, setUserId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function start() {
if (!userId) return;
setBusy(true);
setError(null);
try {
const response = await fetch("/api/impersonation/start", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userId }),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(body?.error ?? "Unable to start impersonation.");
router.push("/");
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Unable to start impersonation.");
} finally {
setBusy(false);
}
}
return (
<div className="max-w-xl rounded-xl border border-border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">Impersonate a user</h1>
<p className="mt-2 text-sm text-muted-foreground">All requests will use the selected user&apos;s identity and permissions until you return to your account.</p>
<div className="mt-6 flex flex-col gap-2">
<Label htmlFor="impersonation-user">User</Label>
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger id="impersonation-user"><SelectValue placeholder="Select a user" /></SelectTrigger>
<SelectContent>
{users.map((user) => <SelectItem key={String(user.id)} value={String(user.id)}>{user.displayName || user.username || `User ${user.id}`} {user.username ? `(${user.username})` : ""}</SelectItem>)}
</SelectContent>
</Select>
</div>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<Button className="mt-6" onClick={start} disabled={!userId || busy}>{busy ? "Starting..." : "Start impersonation"}</Button>
</div>
);
}

View file

@ -0,0 +1,127 @@
"use client";
import { useMemo, useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import type { ImpersonationUser } from "@/lib/impersonation";
import { cn } from "@/lib/utils";
interface ImpersonationModalProps {
users: ImpersonationUser[];
open: boolean;
onOpenChange: (open: boolean) => void;
onStart?: () => void;
}
export function ImpersonationModal({ users, open, onOpenChange, onStart }: ImpersonationModalProps) {
const [userId, setUserId] = useState("");
const [pickerOpen, setPickerOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedUser = useMemo(
() => users.find((user) => String(user.id) === userId) ?? null,
[users, userId],
);
async function startImpersonation() {
if (!userId) return;
setBusy(true);
setError(null);
try {
const response = await fetch("/api/impersonation/start", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userId }),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(body?.error ?? "Unable to start impersonation.");
onOpenChange(false);
onStart?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Unable to start impersonation.");
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Impersonate a user</DialogTitle>
<DialogDescription>
All requests will use the selected user&apos;s identity and permissions until you return to your account.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<Label htmlFor="impersonation-user">User</Label>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button
id="impersonation-user"
variant="outline"
role="combobox"
aria-expanded={pickerOpen}
className="mt-1 w-full justify-between font-normal"
>
{selectedUser
? selectedUser.displayName || selectedUser.username || `User ${selectedUser.id}`
: "Select a user"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or username..." />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={String(user.id)}
value={`${user.displayName ?? ""} ${user.username ?? ""}`.trim()}
onSelect={() => {
setUserId(String(user.id));
setPickerOpen(false);
}}
>
<span className="truncate">
{user.displayName || user.username || `User ${user.id}`}
{user.username ? ` (${user.username})` : ""}
</span>
<Check
className={cn(
"ml-auto size-4",
String(user.id) === userId ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button onClick={startImpersonation} disabled={!userId || busy}>
{busy ? "Starting..." : "Start impersonation"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}