Enhance impersonation functionality and user management

- Updated the ImpersonationBar component to fetch users dynamically and handle impersonation actions more efficiently.
- Refactored the ProfileForm to set the allowImpersonation value directly, improving form handling.
- Modified the DashboardLayout to conditionally render the ImpersonationBar based on user permissions and cloud settings.
- Added a new role column to the user_temp table to support user role management.
- Updated API routes to include checks for root access and improved user listing functionality.
This commit is contained in:
Mauricio Siu 2025-05-06 02:32:08 -06:00
parent cc5574e08a
commit 314438b84c
10 changed files with 5785 additions and 124 deletions

View File

@ -26,7 +26,6 @@ import {
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
@ -46,26 +45,52 @@ 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];
type User = typeof authClient.$Infer.Session.user;
export const ImpersonationBar = () => {
const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isImpersonating, setIsImpersonating] = useState(false);
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = 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 fetchUsers = async (search?: string) => {
try {
const session = await authClient.getSession();
if (session?.data?.session?.impersonatedBy) {
return;
}
setIsLoading(true);
const response = await authClient.admin.listUsers({
query: {
limit: 30,
...(search && {
searchField: "email",
searchOperator: "contains",
searchValue: search,
}),
},
});
const filteredUsers = response.data?.users.filter(
// @ts-ignore
(user) => user.allowImpersonation && data?.user?.email !== user.email,
);
if (!response.error) {
// @ts-ignore
setUsers(filteredUsers || []);
}
} catch (error) {
console.error("Error fetching users:", error);
toast.error("Error loading users");
} finally {
setIsLoading(false);
}
};
const handleImpersonate = async () => {
if (!selectedUser) return;
@ -77,11 +102,6 @@ export const ImpersonationBar = () => {
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}`,
});
@ -121,7 +141,7 @@ export const ImpersonationBar = () => {
};
checkImpersonation();
// fetchUsers();
fetchUsers();
}, []);
return (
@ -159,7 +179,7 @@ export const ImpersonationBar = () => {
showBar ? "translate-y-0" : "translate-y-full",
)}
>
<div className="flex items-center gap-4 px-20 w-full">
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
<Logo className="w-10 h-10" />
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
@ -196,7 +216,7 @@ export const ImpersonationBar = () => {
<CommandInput
placeholder="Search users by email or name..."
onValueChange={(search) => {
setSearchTerm(search);
fetchUsers(search);
}}
className="h-9"
/>
@ -208,46 +228,8 @@ export const ImpersonationBar = () => {
<>
<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) => (
{users.map((user) => (
<CommandItem
key={user.id}
value={user.email}
@ -295,8 +277,8 @@ export const ImpersonationBar = () => {
</Button>
</div>
) : (
<div className="flex items-center gap-4 w-full">
<div className="flex items-center gap-4 flex-1">
<div className="flex items-center gap-4 w-full flex-wrap">
<div className="flex items-center gap-4 flex-1 flex-wrap">
<Avatar className="h-10 w-10">
<AvatarImage
src={data?.user?.image || ""}
@ -319,7 +301,7 @@ export const ImpersonationBar = () => {
{data?.user?.name || ""}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{data?.user?.email} {data?.role}

View File

@ -102,10 +102,7 @@ export const ProfileForm = () => {
keepValues: true,
},
);
form.reset({
allowImpersonation: data?.user?.allowImpersonation,
});
form.setValue("allowImpersonation", data?.user?.allowImpersonation);
if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => {

View File

@ -1,8 +1,6 @@
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;
@ -10,25 +8,12 @@ interface Props {
}
export const DashboardLayout = ({ children }: Props) => {
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;
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
return (
<>
<Page>{children}</Page>
{showImpersonationBar && <ImpersonationBar />}
{haveRootAccess === true && <ImpersonationBar />}
</>
);
};

View File

@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -638,6 +638,13 @@
"when": 1746509318678,
"tag": "0090_clean_wolf_cub",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1746518402168,
"tag": "0091_spotty_kulan_gath",
"breakpoints": true
}
]
}

View File

@ -1,6 +1,5 @@
import {
IS_CLOUD,
auth,
createApiKey,
findOrganizationById,
findUserById,
@ -92,42 +91,18 @@ 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;
}
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;
}
if (
process.env.USER_ADMIN_ID === ctx.user.id ||
ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID
) {
return true;
}
return false;
}),
getBackups: adminProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(

View File

@ -31,7 +31,9 @@ import { ZodError } from "zod";
interface CreateContextOptions {
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
session: (Session & { activeOrganizationId: string }) | null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
req: CreateNextContextOptions["req"];
res: CreateNextContextOptions["res"];
}

View File

@ -57,6 +57,7 @@ export const users_temp = pgTable("user_temp", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
logCleanupCron: text("logCleanupCron"),
role: text("role").notNull().default("user"),
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
@ -135,6 +136,8 @@ export const usersRelations = relations(users_temp, ({ one, many }) => ({
const createSchema = createInsertSchema(users_temp, {
id: z.string().min(1),
isRegistered: z.boolean().optional(),
}).omit({
role: true,
});
export const apiCreateUserInvitation = createSchema.pick({}).extend({

View File

@ -194,7 +194,6 @@ const { handler, api } = betterAuth({
},
},
},
plugins: [
apiKey({
enableMetadata: true,
@ -232,7 +231,6 @@ const { handler, api } = betterAuth({
export const auth = {
handler,
createApiKey: api.createApiKey,
listUsers: api.listUsers,
};
export const validateRequest = async (request: IncomingMessage) => {