feat: implement recent impersonations list with localStorage storage (#23610)

* feat: implement recent impersonations list with localStorage storage

- Add RecentImpersonationsList component with quick action buttons
- Store up to 5 recent impersonations in localStorage using existing webstorage utility
- Integrate component into admin impersonation page above existing form
- Add translation keys for recent impersonations UI elements
- Update impersonation flow to save successful impersonations locally
- Follow existing List component patterns and button styling

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* refactor: optimize RecentImpersonationsList to use lazy initial state

- Replace useEffect with useState lazy initializer for better performance
- Removes unnecessary re-render on component mount
- Remove unused setRecentImpersonations variable to fix ESLint warning
- Follows React best practices for initial state computation

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* Add styling to match v3 and newer designs

* Update packages/lib/recentImpersonations.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2025-09-06 19:56:08 +05:30
committed by GitHub
co-authored by sean@cal.com coderabbitai[bot] Devin AI
parent 29201006d5
commit c0a59a2837
4 changed files with 172 additions and 27 deletions
@@ -0,0 +1,79 @@
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getRecentImpersonations, type RecentImpersonation } from "@calcom/lib/recentImpersonations";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import {
TableNew,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@calcom/ui/components/table";
interface RecentImpersonationsListProps {
onImpersonate?: (username: string) => void;
}
export default function RecentImpersonationsList({ onImpersonate }: RecentImpersonationsListProps) {
const { t } = useLocale();
const [recentImpersonations] = useState<RecentImpersonation[]>(() => getRecentImpersonations());
const handleQuickImpersonate = (username: string) => {
if (onImpersonate) {
onImpersonate(username);
} else {
signIn("impersonation-auth", {
username: username.toLowerCase(),
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}
};
if (recentImpersonations.length === 0) {
return null;
}
return (
<PanelCard title={t("recent_impersonations")}>
<div className="overflow-x-auto">
<TableNew className="border-0">
<TableHeader>
<TableRow>
<TableHead className="w-[40%]">User</TableHead>
<TableHead>Impersonated At</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentImpersonations.map((impersonation) => (
<TableRow key={impersonation.username}>
<TableCell className="font-medium">{impersonation.username}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(impersonation.timestamp).toLocaleDateString()}{" "}
{new Date(impersonation.timestamp).toLocaleTimeString()}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
color="secondary"
size="sm"
onClick={() => handleQuickImpersonate(impersonation.username)}
data-testid={`quick-impersonate-${impersonation.username}`}>
{t("quick_impersonate")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</TableNew>
</div>
</PanelCard>
);
}
@@ -2,23 +2,29 @@
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TextField } from "@calcom/ui/components/form";
import { addRecentImpersonation } from "@calcom/lib/recentImpersonations";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import { TextField } from "@calcom/ui/components/form";
import RecentImpersonationsList from "../../../components/settings/admin/RecentImpersonationsList";
const ImpersonationView = () => {
const { t } = useLocale();
const usernameRef = useRef<HTMLInputElement>(null);
const searchParams = useSearchParams();
const [refreshKey, setRefreshKey] = useState(0);
const username = searchParams?.get("username")?.toLowerCase();
useEffect(() => {
if (username) {
const enteredUsername = username.toLowerCase();
addRecentImpersonation(enteredUsername);
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
@@ -26,32 +32,52 @@ const ImpersonationView = () => {
}
}, [username]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
if (enteredUsername) {
addRecentImpersonation(enteredUsername);
setRefreshKey((prev) => prev + 1);
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}
};
const handleQuickImpersonate = (username: string) => {
if (usernameRef.current) {
usernameRef.current.value = username;
}
addRecentImpersonation(username);
setRefreshKey((prev) => prev + 1);
signIn("impersonation-auth", {
username: username.toLowerCase(),
callbackUrl: `${WEBAPP_URL}/event-types`,
});
};
return (
<form
className="mb-6 w-full"
onSubmit={(e) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TextField
containerClassName="w-full [&_input:-webkit-autofill]:!shadow-[0_0_0_1000px_white_inset]"
name={t("user_impersonation_heading")}
addOnLeading={<>{process.env.NEXT_PUBLIC_WEBSITE_URL}/</>}
ref={usernameRef}
hint={t("impersonate_user_tip")}
defaultValue={undefined}
data-testid="admin-impersonation-input"
/>
<Button type="submit" data-testid="impersonation-submit" className="mt-[-8px]">
{t("impersonate")}
</Button>
</div>
</form>
<div className="flex flex-col gap-4">
<PanelCard title={t("user_impersonation_heading")} subtitle={t("impersonate_user_tip")}>
<form className="p-4" onSubmit={handleSubmit}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TextField
containerClassName="w-full"
name=""
addOnLeading={<>{process.env.NEXT_PUBLIC_WEBSITE_URL}/</>}
ref={usernameRef}
defaultValue={undefined}
data-testid="admin-impersonation-input"
/>
<Button type="submit" data-testid="impersonation-submit">
{t("impersonate")}
</Button>
</div>
</form>
</PanelCard>
<RecentImpersonationsList key={refreshKey} onImpersonate={handleQuickImpersonate} />
</div>
);
};
@@ -1645,6 +1645,9 @@
"embeds": "Embeds",
"impersonation": "Impersonation",
"impersonation_description": "Settings to manage user impersonation",
"recent_impersonations": "Recent Impersonations",
"quick_impersonate": "Quick Impersonate",
"no_recent_impersonations": "No recent impersonations",
"users": "Users",
"user": "User",
"profile_description": "Manage settings for your {{appName}} profile",
+37
View File
@@ -0,0 +1,37 @@
import { localStorage } from "@calcom/lib/webstorage";
export interface RecentImpersonation {
username: string;
timestamp: number;
}
const STORAGE_KEY = "cal-recent-impersonations";
const MAX_RECENT = 5;
export function getRecentImpersonations(): RecentImpersonation[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
} catch {
return [];
}
}
export function addRecentImpersonation(usernameRaw: string): void {
try {
const recent = getRecentImpersonations();
const username = usernameRaw.trim().toLowerCase();
if (!username) return;
const filtered = recent.filter((item) => item.username !== username);
const updated = [{ username, timestamp: Date.now() }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch {}
}
export function clearRecentImpersonations(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}