Add impersonation feature to user management

- Introduced an ImpersonationBar component for admin users to impersonate other users, enhancing user management capabilities.
- Updated the ProfileForm to include an option for allowing impersonation, with a description for clarity.
- Modified the DashboardLayout to conditionally display the impersonation bar based on user roles and cloud settings.
- Added database schema changes to support the new impersonation feature, including a new column for allowImpersonation in the user table.
- Implemented necessary API updates to handle impersonation actions and user data retrieval.
This commit is contained in:
Mauricio Siu 2025-05-06 01:46:20 -06:00
parent 0609d74d2b
commit cc5574e08a
11 changed files with 6322 additions and 3 deletions

View File

@ -0,0 +1,472 @@
"use client";
import { authClient } from "@/lib/auth-client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
CheckIcon,
ChevronsUpDown,
Settings2,
UserIcon,
XIcon,
Shield,
Calendar,
Key,
Copy,
Fingerprint,
Building2,
CreditCard,
Server,
} from "lucide-react";
import { toast } from "sonner";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/shared/logo";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { format } from "date-fns";
import copy from "copy-to-clipboard";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
type User = RouterOutputs["user"]["listUsers"]["users"][number];
export const ImpersonationBar = () => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isImpersonating, setIsImpersonating] = useState(false);
const [open, setOpen] = useState(false);
const [showBar, setShowBar] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [recentlyAccessed, setRecentlyAccessed] = useState<User[]>([]);
const { data } = api.user.get.useQuery();
const { data: users, isLoading } = api.user.listUsers.useQuery(
{
search: searchTerm,
},
{
enabled: open && !isImpersonating,
},
);
const handleImpersonate = async () => {
if (!selectedUser) return;
try {
await authClient.admin.impersonateUser({
userId: selectedUser.id,
});
setIsImpersonating(true);
setOpen(false);
setRecentlyAccessed((prev) => {
const filtered = prev.filter((u) => u.id !== selectedUser.id);
return [selectedUser, ...filtered].slice(0, 5);
});
toast.success("Successfully impersonating user", {
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
});
window.location.reload();
} catch (error) {
console.error("Error impersonating user:", error);
toast.error("Error impersonating user");
}
};
const handleStopImpersonating = async () => {
try {
await authClient.admin.stopImpersonating();
setIsImpersonating(false);
setSelectedUser(null);
setShowBar(false);
toast.success("Stopped impersonating user");
window.location.reload();
} catch (error) {
console.error("Error stopping impersonation:", error);
toast.error("Error stopping impersonation");
}
};
useEffect(() => {
const checkImpersonation = async () => {
try {
const session = await authClient.getSession();
if (session?.data?.session?.impersonatedBy) {
setIsImpersonating(true);
setShowBar(true);
// setSelectedUser(data);
}
} catch (error) {
console.error("Error checking impersonation status:", error);
}
};
checkImpersonation();
// fetchUsers();
}, []);
return (
<TooltipProvider>
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"fixed bottom-4 right-4 z-50 rounded-full shadow-lg",
isImpersonating &&
!showBar &&
"bg-red-100 hover:bg-red-200 border-red-200",
)}
onClick={() => setShowBar(!showBar)}
>
<Settings2
className={cn(
"h-4 w-4",
isImpersonating && !showBar && "text-red-500",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{isImpersonating ? "Impersonation Controls" : "User Impersonation"}
</TooltipContent>
</Tooltip>
<div
className={cn(
"fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 flex items-center justify-center gap-4 z-40 transition-all duration-200 ease-in-out",
showBar ? "translate-y-0" : "translate-y-full",
)}
>
<div className="flex items-center gap-4 px-20 w-full">
<Logo className="w-10 h-10" />
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-[300px] justify-between"
>
{selectedUser ? (
<div className="flex items-center gap-2">
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate flex flex-col items-start">
<span className="text-sm font-medium">
{selectedUser.name || ""}
</span>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</span>
</div>
) : (
<>
<UserIcon className="mr-2 h-4 w-4" />
<span>Select user to impersonate</span>
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search users by email or name..."
onValueChange={(search) => {
setSearchTerm(search);
}}
className="h-9"
/>
{isLoading ? (
<div className="py-6 text-center text-sm">
Loading users...
</div>
) : (
<>
<CommandEmpty>No users found.</CommandEmpty>
<CommandList>
{recentlyAccessed.length > 0 && !searchTerm && (
<>
<CommandGroup heading="Recently Accessed">
{recentlyAccessed.map((user) => (
<CommandItem
key={user.id}
value={user.email}
onSelect={() => {
setSelectedUser(user);
setOpen(false);
}}
>
<span className="flex items-center gap-2 flex-1">
<UserIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="flex flex-col items-start">
<span className="text-sm font-medium">
{user.name || ""}
</span>
<span className="text-xs text-muted-foreground">
{user.email} {user.role}
</span>
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
selectedUser?.id === user.id
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="All Users">
{users?.users.map((user) => (
<CommandItem
key={user.id}
value={user.email}
onSelect={() => {
setSelectedUser(user);
setOpen(false);
}}
>
<span className="flex items-center gap-2 flex-1">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<span className="flex flex-col items-start">
<span className="text-sm font-medium">
{user.name || ""}
</span>
<span className="text-xs text-muted-foreground">
{user.email} {user.role}
</span>
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
selectedUser?.id === user.id
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</>
)}
</Command>
</PopoverContent>
</Popover>
<Button
onClick={handleImpersonate}
disabled={!selectedUser}
variant="default"
className="gap-2"
>
<Shield className="h-4 w-4" />
Impersonate
</Button>
</div>
) : (
<div className="flex items-center gap-4 w-full">
<div className="flex items-center gap-4 flex-1">
<Avatar className="h-10 w-10">
<AvatarImage
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
/>
<AvatarFallback>
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="gap-1 py-1 text-yellow-500 bg-yellow-50/20"
>
<Shield className="h-3 w-3" />
Impersonating
</Badge>
<span className="font-medium">
{data?.user?.name || ""}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{data?.user?.email} {data?.role}
</span>
<span className="flex items-center gap-1">
<Key className="h-3 w-3" />
<span className="flex items-center gap-1">
ID: {data?.user?.id?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
if (data?.id) {
copy(data.id);
toast.success("ID copied to clipboard");
}
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
<span className="flex items-center gap-1">
<Building2 className="h-3 w-3" />
<span className="flex items-center gap-1">
Org: {data?.organizationId?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
if (data?.organizationId) {
copy(data.organizationId);
toast.success(
"Organization ID copied to clipboard",
);
}
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
{data?.user?.stripeCustomerId && (
<span className="flex items-center gap-1">
<CreditCard className="h-3 w-3" />
<span className="flex items-center gap-1">
Customer:
{data?.user?.stripeCustomerId?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
copy(data?.user?.stripeCustomerId || "");
toast.success(
"Stripe Customer ID copied to clipboard",
);
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
)}
{data?.user?.stripeSubscriptionId && (
<span className="flex items-center gap-1">
<CreditCard className="h-3 w-3" />
<span className="flex items-center gap-1">
Sub: {data?.user?.stripeSubscriptionId?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
copy(data.user.stripeSubscriptionId || "");
toast.success(
"Stripe Subscription ID copied to clipboard",
);
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
)}
{data?.user?.serversQuantity !== undefined && (
<span className="flex items-center gap-1">
<Server className="h-3 w-3" />
<span>Servers: {data.user.serversQuantity}</span>
</span>
)}
{data?.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Created:{" "}
{format(new Date(data.createdAt), "MMM d, yyyy")}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-default">
<Fingerprint
className={cn(
"h-3 w-3",
data?.user?.twoFactorEnabled
? "text-green-500"
: "text-muted-foreground",
)}
/>
<Badge
variant={
data?.user?.twoFactorEnabled
? "green"
: "secondary"
}
className="text-[10px] px-1 py-0"
>
2FA{" "}
{data?.user?.twoFactorEnabled
? "Enabled"
: "Disabled"}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
Two-Factor Authentication Status
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<Button
onClick={handleStopImpersonating}
variant="secondary"
className="gap-2"
size="sm"
>
<XIcon className="w-4 h-4" />
Stop Impersonating
</Button>
</div>
)}
</div>
</div>
</>
</TooltipProvider>
);
};

View File

@ -10,6 +10,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@ -28,12 +29,14 @@ import { toast } from "sonner";
import { z } from "zod";
import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
import { Switch } from "@/components/ui/switch";
const profileSchema = z.object({
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
type Profile = z.infer<typeof profileSchema>;
@ -56,6 +59,7 @@ const randomImages = [
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const {
mutateAsync,
@ -79,6 +83,7 @@ export const ProfileForm = () => {
password: "",
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
},
resolver: zodResolver(profileSchema),
});
@ -91,12 +96,17 @@ export const ProfileForm = () => {
password: form.getValues("password") || "",
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
},
{
keepValues: true,
},
);
form.reset({
allowImpersonation: data?.user?.allowImpersonation,
});
if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => {
setGravatarHash(hash);
@ -111,6 +121,7 @@ export const ProfileForm = () => {
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
})
.then(async () => {
await refetch();
@ -256,7 +267,34 @@ export const ProfileForm = () => {
</FormItem>
)}
/>
{isCloud && (
<FormField
control={form.control}
name="allowImpersonation"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow Impersonation</FormLabel>
<FormDescription>
Enable this option to allow Dokploy Cloud
administrators to temporarily access your
account for troubleshooting and support
purposes. This helps them quickly identify and
resolve any issues you may encounter.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
{t("settings.common.save")}

View File

@ -1,9 +1,34 @@
import Page from "./side";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { api } from "@/utils/api";
import { authClient } from "@/lib/auth-client";
import { useEffect, useState } from "react";
interface Props {
children: React.ReactNode;
metaName?: string;
}
export const DashboardLayout = ({ children }: Props) => {
return <Page>{children}</Page>;
const { data: user } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const [isBeingImpersonated, setIsBeingImpersonated] = useState(false);
const isAdmin = user?.role === "admin" || user?.role === "owner";
useEffect(() => {
const checkImpersonation = async () => {
const session = await authClient.getSession();
setIsBeingImpersonated(!!session?.data?.session?.impersonatedBy);
};
checkImpersonation();
}, []);
const showImpersonationBar = (isAdmin || isBeingImpersonated) && isCloud;
return (
<>
<Page>{children}</Page>
{showImpersonationBar && <ImpersonationBar />}
</>
);
};

View File

@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "allowImpersonation" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -631,6 +631,13 @@
"when": 1746392564463,
"tag": "0089_noisy_sandman",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1746509318678,
"tag": "0090_clean_wolf_cub",
"breakpoints": true
}
]
}

View File

@ -1,9 +1,15 @@
import { organizationClient } from "better-auth/client/plugins";
import { twoFactorClient } from "better-auth/client/plugins";
import { apiKeyClient } from "better-auth/client/plugins";
import { adminClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// baseURL: "http://localhost:3000", // the base url of your auth server
plugins: [organizationClient(), twoFactorClient(), apiKeyClient()],
plugins: [
organizationClient(),
twoFactorClient(),
apiKeyClient(),
adminClient(),
],
});

View File

@ -1,5 +1,6 @@
import {
IS_CLOUD,
auth,
createApiKey,
findOrganizationById,
findUserById,
@ -91,6 +92,42 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
listUsers: adminProcedure
.input(
z.object({
search: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
try {
console.log(process.env.USER_ADMIN_ID, ctx.user.id);
if (process.env.USER_ADMIN_ID !== ctx.user.id) {
return [];
}
const users = await auth.listUsers({
query: {
limit: 100,
filterField: "allowImpersonation",
filterOperator: "eq",
filterValue: true,
...(input.search && {
searchField: "email",
searchOperator: "contains",
searchValue: input.search,
}),
},
// @ts-ignore
headers: {
cookie: ctx.req.headers.cookie,
},
});
console.log(users);
return users;
} catch (error) {
console.log(error);
throw error;
}
}),
getBackups: adminProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(

View File

@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -59,6 +59,7 @@ export const users_temp = pgTable("user_temp", {
logCleanupCron: text("logCleanupCron"),
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {

View File

@ -3,7 +3,7 @@ import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { apiKey, organization, twoFactor } from "better-auth/plugins";
import { apiKey, organization, twoFactor, admin } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { db } from "../db";
@ -187,6 +187,11 @@ const { handler, api } = betterAuth({
// required: true,
input: false,
},
allowImpersonation: {
fieldName: "allowImpersonation",
type: "boolean",
defaultValue: false,
},
},
},
@ -214,12 +219,20 @@ const { handler, api } = betterAuth({
}
},
}),
...(IS_CLOUD
? [
admin({
adminUserIds: [process.env.USER_ADMIN_ID as string],
}),
]
: []),
],
});
export const auth = {
handler,
createApiKey: api.createApiKey,
listUsers: api.listUsers,
};
export const validateRequest = async (request: IncomingMessage) => {