feat: implement user impersonation

This commit is contained in:
giriparus
2026-02-03 11:51:35 +05:30
parent 8f84fbf562
commit bc38dba859
9 changed files with 302 additions and 16 deletions
@@ -1,4 +1,3 @@
import React from "react";
import {
Dialog,
@@ -18,6 +17,8 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import UserTextField from "./form-fields/UserTextField";
import UserToggleField from "./form-fields/UserToggleField";
import UserRoleField from "./form-fields/UserRoleField";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
interface EditUserDialogProps {
isOpen: boolean;
@@ -25,6 +26,7 @@ interface EditUserDialogProps {
form: UseFormReturn<any>;
user: User | null;
onSubmit: (data: any) => void;
onImpersonate: (data: any) => void;
isSubmitting?: boolean;
error?: string | null;
}
@@ -35,10 +37,18 @@ const EditUserDialog = ({
form,
user,
onSubmit,
onImpersonate,
isSubmitting = false,
error = null
}: EditUserDialogProps) => {
if (!user) return null;
const [impersonationDurationSeconds, setImpersonationDurationSeconds] = React.useState<number>(3600);
const handleImpersonateClick = () => {
// Impersonation should not depend on edit form validation.
onImpersonate({ user, durationSeconds: impersonationDurationSeconds });
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -100,6 +110,23 @@ const EditUserDialog = ({
/>
</form>
</Form>
{/* Impersonation settings are not part of the form to avoid validation */}
<div className="mt-6 space-y-2">
<Label htmlFor="impersonation-duration">Impersonation token duration (seconds)</Label>
<Input
id="impersonation-duration"
type="number"
inputMode="numeric"
min={60}
step={60}
value={impersonationDurationSeconds}
onChange={(e) => setImpersonationDurationSeconds(Number(e.target.value || 0))}
/>
<p className="text-xs text-muted-foreground">
Default is 3600 (1 hour). Minimum 60 seconds.
</p>
</div>
</div>
</ScrollArea>
@@ -125,6 +152,20 @@ const EditUserDialog = ({
"Update User"
)}
</Button>
<Button
type="button"
onClick={handleImpersonateClick}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Impersonating...
</>
) : (
"Impersonate"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,65 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/utils/copyUtils";
interface ImpersonationTokenDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
token: string | null;
impersonatedUserLabel?: string;
}
const ImpersonationTokenDialog = ({
open,
onOpenChange,
token,
impersonatedUserLabel,
}: ImpersonationTokenDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[650px] w-[95vw]">
<DialogHeader>
<DialogTitle>Impersonation token</DialogTitle>
<DialogDescription>
{impersonatedUserLabel
? `Use this token to impersonate ${impersonatedUserLabel}. Keep it secret.`
: "Use this token to impersonate the selected user. Keep it secret."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<div className="rounded-md border bg-muted p-3">
<pre className="text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{token ?? ""}</code>
</pre>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
type="button"
onClick={() => {
if (token) copyToClipboard(token);
}}
disabled={!token}
>
Copy token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ImpersonationTokenDialog;
@@ -1,9 +1,9 @@
import React from "react";
import UserTable from "./UserTable";
import AddUserDialog from "./AddUserDialog";
import EditUserDialog from "./EditUserDialog";
import DeleteUserDialog from "./DeleteUserDialog";
import ImpersonationTokenDialog from "./ImpersonationTokenDialog";
import { useUserManagement } from "./hooks";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { UserCog, Loader2, AlertCircle, Info, Users, ShieldAlert } from "lucide-react";
@@ -34,8 +34,12 @@ const UserManagement = () => {
handleDeletePrompt,
handleDeleteUser,
onSubmit,
onImpersonate,
onAddUser,
fetchUsers
fetchUsers,
impersonationToken,
isImpersonationTokenDialogOpen,
setIsImpersonationTokenDialogOpen,
} = useUserManagement();
// Get the current logged in user to check their role
@@ -79,7 +83,14 @@ const UserManagement = () => {
<AddUserDialog isOpen={isAddUserDialogOpen && isSuperAdmin} setIsOpen={setIsAddUserDialogOpen} form={newUserForm} onSubmit={onAddUser} isSubmitting={isSubmitting} />
<EditUserDialog isOpen={isDialogOpen} setIsOpen={setIsDialogOpen} form={form} user={currentUser} onSubmit={onSubmit} isSubmitting={isSubmitting} error={updateError} />
<EditUserDialog isOpen={isDialogOpen} setIsOpen={setIsDialogOpen} form={form} user={currentUser} onImpersonate={onImpersonate} onSubmit={onSubmit} isSubmitting={isSubmitting} error={updateError} />
<ImpersonationTokenDialog
open={isImpersonationTokenDialogOpen}
onOpenChange={setIsImpersonationTokenDialogOpen}
token={impersonationToken}
impersonatedUserLabel={currentUser?.full_name || currentUser?.username}
/>
<DeleteUserDialog isOpen={isDeleting} setIsOpen={setIsDeleting} user={userToDelete} onDelete={handleDeleteUser} isDeleting={isSubmitting} />
</AccordionContent>
@@ -1,10 +1,10 @@
import { useUsersList } from "./useUsersList";
import { useUserForm } from "./useUserForm";
import { useUserDialogs } from "./useUserDialogs";
import { useUserOperations } from "./useUserOperations";
import { User } from "@/services/userService";
import { UserFormValues, NewUserFormValues } from "../userForms";
import { useState } from "react";
export const useUserManagement = () => {
const { users, loading, error, fetchUsers } = useUsersList();
@@ -32,6 +32,7 @@ export const useUserManagement = () => {
handleDeleteUser: baseHandleDeleteUser,
onSubmit: baseOnSubmit,
onAddUser: baseOnAddUser,
onImpersonate: baseOnImpersonate,
} = useUserOperations(
fetchUsers,
setIsDialogOpen,
@@ -42,6 +43,9 @@ export const useUserManagement = () => {
newUserForm.reset
);
const [impersonationToken, setImpersonationToken] = useState<string | null>(null);
const [isImpersonationTokenDialogOpen, setIsImpersonationTokenDialogOpen] = useState(false);
// Wrapper functions to provide the needed arguments
const handleEditUser = (user: User) => {
baseHandleEditUser(user, form);
@@ -56,6 +60,15 @@ export const useUserManagement = () => {
baseOnSubmit(data, currentUser);
};
const onImpersonate = async (data: UserFormValues) => {
const token = await baseOnImpersonate(data, currentUser);
if (token) {
setImpersonationToken(token);
setIsImpersonationTokenDialogOpen(true);
}
};
const onAddUser = (data: NewUserFormValues) => {
baseOnAddUser(data);
};
@@ -81,6 +94,10 @@ export const useUserManagement = () => {
handleDeletePrompt,
handleDeleteUser,
onSubmit,
onImpersonate,
onAddUser,
impersonationToken,
isImpersonationTokenDialogOpen,
setIsImpersonationTokenDialogOpen,
};
};
@@ -1,4 +1,3 @@
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService";
@@ -121,6 +120,38 @@ export const useUserOperations = (
}
};
const onImpersonate = async (
data: UserFormValues & { durationSeconds?: number },
currentUser: User | null
): Promise<string | undefined> => {
const loggedInUser = authService.getCurrentUser();
if (!currentUser) return;
try {
const token = await authService.impersonateUser(currentUser.id, data?.durationSeconds ?? 3600);
toast({
title: "Impersonation token generated",
description: `Token generated for ${currentUser.full_name || currentUser.username}.`,
});
return token;
} catch (error) {
let description = "Error impersonating user.";
if (loggedInUser?.role !== "superadmin") {
description = "Only superadmin users can impersonate other users.";
}
toast({
title: "Error impersonating user",
description,
variant: "destructive",
});
throw error;
}
};
const onAddUser = async (data: NewUserFormValues) => {
setIsSubmitting(true);
try {
@@ -177,6 +208,7 @@ export const useUserOperations = (
return {
handleDeleteUser,
onSubmit,
onImpersonate,
onAddUser,
};
};
+5 -1
View File
@@ -1,4 +1,3 @@
import { pb } from '@/lib/pocketbase';
export interface LoginCredentials {
@@ -98,5 +97,10 @@ export const authService = {
} catch (error) {
// console.error('Failed to refresh user data:', error);
}
},
async impersonateUser(userId: string, durationSeconds: number = 3600): Promise<string | undefined> {
const impersonateClient = await pb.collection("users").impersonate(userId, durationSeconds);
return impersonateClient.authStore?.token;
}
};