feat: implement user impersonation
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user