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:
co-authored by
sean@cal.com
coderabbitai[bot]
Devin AI
parent
29201006d5
commit
c0a59a2837
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user