Skip to content

Commit 1d15f05

Browse files
committed
Add user management features for organizations
- Implemented useAddUserToOrganization and useRemoveUserFromOrganization hooks for adding and removing users from organizations. - Enhanced the CreateUserDialog to utilize the new add user functionality. - Updated the UsersTable component to include options for adding and removing users from organizations with confirmation dialogs. - Improved organization member display with user ID copy functionality and refined UI elements for better user experience. - Adjusted server-side logic to ensure proper authorization checks when adding users to organizations.
1 parent ca0da05 commit 1d15f05

File tree

6 files changed

+432
-48
lines changed

6 files changed

+432
-48
lines changed

client/src/api/admin/organizations.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useQuery } from "@tanstack/react-query";
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
import { authedFetch } from "../utils";
33
import { authClient } from "../../lib/auth";
44

@@ -43,3 +43,63 @@ export function useOrganizationInvitations(organizationId: string) {
4343
},
4444
});
4545
}
46+
47+
interface AddUserToOrganizationInput {
48+
email: string;
49+
role: string;
50+
organizationId: string;
51+
}
52+
53+
export function useAddUserToOrganization() {
54+
const queryClient = useQueryClient();
55+
56+
return useMutation<{ message: string }, Error, AddUserToOrganizationInput>({
57+
mutationFn: async ({ email, role, organizationId }: AddUserToOrganizationInput) => {
58+
try {
59+
return await authedFetch<{ message: string }>("/add-user-to-organization", undefined, {
60+
method: "POST",
61+
data: {
62+
email,
63+
role,
64+
organizationId,
65+
},
66+
});
67+
} catch (error) {
68+
throw new Error(error instanceof Error ? error.message : "Failed to add user to organization");
69+
}
70+
},
71+
onSuccess: () => {
72+
queryClient.invalidateQueries({ queryKey: ["admin-organizations"] });
73+
queryClient.invalidateQueries({ queryKey: [USER_ORGANIZATIONS_QUERY_KEY] });
74+
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
75+
},
76+
});
77+
}
78+
79+
interface RemoveUserFromOrganizationInput {
80+
memberIdOrEmail: string;
81+
organizationId: string;
82+
}
83+
84+
export function useRemoveUserFromOrganization() {
85+
const queryClient = useQueryClient();
86+
87+
return useMutation<void, Error, RemoveUserFromOrganizationInput>({
88+
mutationFn: async ({ memberIdOrEmail, organizationId }: RemoveUserFromOrganizationInput) => {
89+
try {
90+
await authClient.organization.removeMember({
91+
memberIdOrEmail,
92+
organizationId,
93+
});
94+
} catch (error) {
95+
throw new Error(error instanceof Error ? error.message : "Failed to remove user from organization");
96+
}
97+
},
98+
onSuccess: () => {
99+
queryClient.invalidateQueries({ queryKey: ["admin-organizations"] });
100+
queryClient.invalidateQueries({ queryKey: [USER_ORGANIZATIONS_QUERY_KEY] });
101+
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
102+
},
103+
});
104+
}
105+

client/src/app/admin/components/organizations/Organizations.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { ServiceUsageChart } from "../shared/ServiceUsageChart";
4444
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4545
import { Switch } from "@/components/ui/switch";
4646
import { Label } from "@/components/ui/label";
47+
import { CopyText } from "../../../../components/CopyText";
4748

4849
export function Organizations() {
4950
const router = useRouter();
@@ -507,6 +508,7 @@ export function Organizations() {
507508
<TableCell colSpan={columns.length} className="bg-neutral-900 py-4 px-8">
508509
<div className="space-y-6">
509510
{/* Subscription Details */}
511+
<CopyText text={row.original.id}></CopyText>
510512
<div>
511513
<div className="flex items-center gap-2 text-sm font-semibold mb-3">
512514
<CreditCard className="h-4 w-4" />
@@ -592,25 +594,28 @@ export function Organizations() {
592594
key={member.userId}
593595
className="p-3 border border-neutral-700 rounded flex items-center justify-between"
594596
>
595-
<div>
597+
<div className="flex flex-col gap-1">
596598
<div className="font-medium flex items-center gap-2">
597599
{member.name}{" "}
598600
<Badge variant="outline" className="text-xs">
599601
{member.role}
600602
</Badge>
601603
</div>
602-
<div className="text-sm text-neutral-400">{member.email}</div>
604+
<div className="text-sm text-neutral-200">{member.email}</div>
605+
<div className="text-xs text-neutral-400">
606+
<CopyText text={member.userId} className="text-xs"></CopyText>
607+
</div>
608+
<Button
609+
onClick={() => handleImpersonate(member.userId)}
610+
size="sm"
611+
variant="outline"
612+
className="flex items-center gap-1"
613+
disabled={member.userId === userStore.getState().user?.id}
614+
>
615+
<UserCheck className="h-3 w-3" />
616+
Impersonate
617+
</Button>
603618
</div>
604-
<Button
605-
onClick={() => handleImpersonate(member.userId)}
606-
size="sm"
607-
variant="outline"
608-
className="flex items-center gap-1"
609-
disabled={member.userId === userStore.getState().user?.id}
610-
>
611-
<UserCheck className="h-3 w-3" />
612-
Impersonate
613-
</Button>
614619
</div>
615620
))}
616621
</div>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/components/ui/dialog";
12+
import { Label } from "@/components/ui/label";
13+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
14+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
15+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
16+
import { Check, ChevronsUpDown } from "lucide-react";
17+
import { useState } from "react";
18+
import { toast } from "sonner";
19+
import { Alert } from "@/components/ui/alert";
20+
import { useAddUserToOrganization } from "@/api/admin/organizations";
21+
import { useAdminOrganizations } from "@/api/admin/getAdminOrganizations";
22+
import { cn } from "@/lib/utils";
23+
24+
interface AddToOrganizationDialogProps {
25+
userEmail: string;
26+
userId: string;
27+
open: boolean;
28+
onOpenChange: (open: boolean) => void;
29+
}
30+
31+
export function AddToOrganizationDialog({ userEmail, userId, open, onOpenChange }: AddToOrganizationDialogProps) {
32+
const [organizationId, setOrganizationId] = useState<string>("");
33+
const [role, setRole] = useState<"admin" | "member" | "owner">("member");
34+
const [error, setError] = useState("");
35+
const [comboboxOpen, setComboboxOpen] = useState(false);
36+
37+
const { data: organizations, isLoading: isLoadingOrgs } = useAdminOrganizations();
38+
const addUserToOrganization = useAddUserToOrganization();
39+
40+
const resetState = (open: boolean) => {
41+
onOpenChange(open);
42+
if (!open) {
43+
setError("");
44+
setOrganizationId("");
45+
setRole("member");
46+
}
47+
};
48+
49+
const handleAdd = async () => {
50+
if (!organizationId) {
51+
setError("Please select an organization");
52+
return;
53+
}
54+
55+
try {
56+
await addUserToOrganization.mutateAsync({
57+
email: userEmail,
58+
role,
59+
organizationId,
60+
});
61+
62+
toast.success("User added to organization successfully");
63+
resetState(false);
64+
} catch (error: any) {
65+
setError(error.message || "Failed to add user to organization");
66+
}
67+
};
68+
69+
return (
70+
<Dialog open={open} onOpenChange={resetState}>
71+
<DialogContent className="max-w-lg">
72+
<DialogHeader>
73+
<DialogTitle>Add user to organization</DialogTitle>
74+
<DialogDescription>
75+
Add {userEmail} to an organization with a specific role.
76+
</DialogDescription>
77+
</DialogHeader>
78+
<div className="grid gap-4 py-4">
79+
<div className="grid gap-2">
80+
<Label htmlFor="organization">Organization</Label>
81+
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
82+
<PopoverTrigger asChild>
83+
<Button
84+
variant="outline"
85+
role="combobox"
86+
aria-expanded={comboboxOpen}
87+
className="w-full justify-between"
88+
disabled={isLoadingOrgs}
89+
>
90+
{organizationId
91+
? organizations?.find(org => org.id === organizationId)?.name
92+
: isLoadingOrgs
93+
? "Loading..."
94+
: "Select an organization..."}
95+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
96+
</Button>
97+
</PopoverTrigger>
98+
<PopoverContent className="w-full p-0" align="start">
99+
<Command
100+
filter={(value, search) => {
101+
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
102+
return 0;
103+
}}
104+
>
105+
<CommandInput placeholder="Search organizations..." />
106+
<CommandList>
107+
<CommandEmpty>No organization found.</CommandEmpty>
108+
<CommandGroup>
109+
{organizations?.map(org => (
110+
<CommandItem
111+
key={org.id}
112+
value={`${org.name} ${org.id}`}
113+
onSelect={() => {
114+
setOrganizationId(org.id);
115+
setComboboxOpen(false);
116+
}}
117+
>
118+
<Check className={cn("mr-2 h-4 w-4", organizationId === org.id ? "opacity-100" : "opacity-0")} />
119+
{org.name}
120+
</CommandItem>
121+
))}
122+
</CommandGroup>
123+
</CommandList>
124+
</Command>
125+
</PopoverContent>
126+
</Popover>
127+
</div>
128+
<div className="grid gap-2">
129+
<Label htmlFor="role">Role</Label>
130+
<Select value={role} onValueChange={value => setRole(value as "admin" | "member" | "owner")}>
131+
<SelectTrigger id="role">
132+
<SelectValue placeholder="Select a role" />
133+
</SelectTrigger>
134+
<SelectContent>
135+
<SelectItem value="owner">Owner</SelectItem>
136+
<SelectItem value="admin">Admin</SelectItem>
137+
<SelectItem value="member">Member</SelectItem>
138+
</SelectContent>
139+
</Select>
140+
</div>
141+
{error && <Alert variant="destructive">{error}</Alert>}
142+
</div>
143+
<DialogFooter>
144+
<Button variant="outline" onClick={() => resetState(false)}>
145+
Cancel
146+
</Button>
147+
<Button onClick={handleAdd} disabled={addUserToOrganization.isPending} variant="success">
148+
{addUserToOrganization.isPending ? "Adding..." : "Add to Organization"}
149+
</Button>
150+
</DialogFooter>
151+
</DialogContent>
152+
</Dialog>
153+
);
154+
}

0 commit comments

Comments
 (0)