Skip to content

Commit aa47c9d

Browse files
Claudeos-zhuang
andauthored
feat: enhance organization detail page with member management and invitation UI
Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/3cfaa60d-95bc-44d6-ab1d-973416e6c71a Co-authored-by: os-zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 6f4d54f commit aa47c9d

1 file changed

Lines changed: 279 additions & 49 deletions

File tree

apps/studio/src/routes/orgs.$orgId.tsx

Lines changed: 279 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,35 @@
22

33
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
44
import { useEffect, useState } from 'react';
5-
import { ArrowLeft } from 'lucide-react';
5+
import { ArrowLeft, Mail, MoreVertical, Trash2, UserPlus, Users } from 'lucide-react';
66
import { useClient } from '@objectstack/client-react';
77
import { Button } from '@/components/ui/button';
88
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogHeader,
15+
DialogTitle,
16+
} from '@/components/ui/dialog';
17+
import {
18+
DropdownMenu,
19+
DropdownMenuContent,
20+
DropdownMenuItem,
21+
DropdownMenuTrigger,
22+
} from '@/components/ui/dropdown-menu';
23+
import { Input } from '@/components/ui/input';
24+
import { Label } from '@/components/ui/label';
25+
import {
26+
Select,
27+
SelectContent,
28+
SelectItem,
29+
SelectTrigger,
30+
SelectValue,
31+
} from '@/components/ui/select';
32+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
33+
import { Badge } from '@/components/ui/badge';
934
import { toast } from '@/hooks/use-toast';
1035
import { useOrganizations, useSession } from '@/hooks/useSession';
1136

@@ -22,27 +47,29 @@ function OrgDetailPage() {
2247
const org = organizations.find((o) => o.id === orgId);
2348
const [members, setMembers] = useState<any[]>([]);
2449
const [loadingMembers, setLoadingMembers] = useState(false);
50+
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
51+
52+
// Invitation form state
53+
const [inviteEmail, setInviteEmail] = useState('');
54+
const [inviteRole, setInviteRole] = useState('member');
55+
const [inviting, setInviting] = useState(false);
2556

2657
useEffect(() => {
27-
let cancelled = false;
28-
async function loadMembers() {
29-
if (!client?.organizations) return;
30-
setLoadingMembers(true);
31-
try {
32-
const res = await client.organizations.listMembers(orgId);
33-
if (cancelled) return;
34-
setMembers(res?.members ?? res?.data?.members ?? res ?? []);
35-
} catch {
36-
if (!cancelled) setMembers([]);
37-
} finally {
38-
if (!cancelled) setLoadingMembers(false);
39-
}
40-
}
4158
loadMembers();
42-
return () => {
43-
cancelled = true;
44-
};
45-
}, [client, orgId]);
59+
}, [orgId]);
60+
61+
async function loadMembers() {
62+
if (!client?.organizations) return;
63+
setLoadingMembers(true);
64+
try {
65+
const res = await client.organizations.listMembers(orgId);
66+
setMembers(res?.members ?? res?.data?.members ?? res ?? []);
67+
} catch {
68+
setMembers([]);
69+
} finally {
70+
setLoadingMembers(false);
71+
}
72+
}
4673

4774
const handleSetActive = async () => {
4875
try {
@@ -57,12 +84,45 @@ function OrgDetailPage() {
5784
}
5885
};
5986

87+
const handleInviteMember = async () => {
88+
if (!inviteEmail) {
89+
toast({
90+
title: 'Email required',
91+
description: 'Please enter an email address',
92+
variant: 'destructive',
93+
});
94+
return;
95+
}
96+
97+
setInviting(true);
98+
try {
99+
await client.organizations.invite({
100+
email: inviteEmail,
101+
role: inviteRole,
102+
organizationId: orgId,
103+
});
104+
toast({ title: 'Invitation sent successfully' });
105+
setInviteDialogOpen(false);
106+
setInviteEmail('');
107+
setInviteRole('member');
108+
loadMembers();
109+
} catch (err) {
110+
toast({
111+
title: 'Failed to send invitation',
112+
description: (err as Error).message,
113+
variant: 'destructive',
114+
});
115+
} finally {
116+
setInviting(false);
117+
}
118+
};
119+
60120
const isActive = session?.activeOrganizationId === orgId;
61121

62122
return (
63123
<div className="flex flex-1 flex-col overflow-hidden">
64124
<div className="flex-1 px-6 py-8 overflow-auto">
65-
<div className="mx-auto max-w-3xl space-y-6">
125+
<div className="mx-auto max-w-4xl space-y-6">
66126
<div className="flex items-center justify-between">
67127
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/orgs' })}>
68128
<ArrowLeft className="mr-1 h-4 w-4" /> Back
@@ -73,49 +133,164 @@ function OrgDetailPage() {
73133
</Button>
74134
)}
75135
</div>
136+
137+
{/* Organization Overview Card */}
76138
<Card>
77139
<CardHeader>
78-
<CardTitle>{org?.name ?? 'Organization'}</CardTitle>
79-
{org?.slug && (
80-
<CardDescription className="font-mono text-xs">{org.slug}</CardDescription>
81-
)}
140+
<div className="flex items-center justify-between">
141+
<div>
142+
<CardTitle>{org?.name ?? 'Organization'}</CardTitle>
143+
{org?.slug && (
144+
<CardDescription className="font-mono text-xs mt-1">
145+
{org.slug}
146+
</CardDescription>
147+
)}
148+
</div>
149+
{isActive && (
150+
<Badge variant="outline" className="ml-2">
151+
Active
152+
</Badge>
153+
)}
154+
</div>
82155
</CardHeader>
83156
<CardContent className="space-y-3 text-sm">
84157
<div className="flex justify-between">
85158
<span className="text-muted-foreground">ID</span>
86159
<code className="text-xs">{orgId}</code>
87160
</div>
88161
<div className="flex justify-between">
89-
<span className="text-muted-foreground">Status</span>
90-
<span>{isActive ? 'Active' : 'Inactive'}</span>
162+
<span className="text-muted-foreground">Members</span>
163+
<span>{members.length}</span>
91164
</div>
92165
</CardContent>
93166
</Card>
94-
<Card>
95-
<CardHeader>
96-
<CardTitle className="text-base">Members</CardTitle>
97-
<CardDescription>People with access to this organization.</CardDescription>
98-
</CardHeader>
99-
<CardContent>
100-
{loadingMembers && <p className="text-xs text-muted-foreground">Loading…</p>}
101-
{!loadingMembers && members.length === 0 && (
102-
<p className="text-xs text-muted-foreground">No members found.</p>
103-
)}
104-
<ul className="divide-y">
105-
{members.map((m, i) => (
106-
<li key={m.id ?? i} className="flex items-center justify-between py-2 text-sm">
167+
168+
{/* Tabs for Members and Invitations */}
169+
<Tabs defaultValue="members" className="w-full">
170+
<TabsList className="grid w-full grid-cols-2">
171+
<TabsTrigger value="members">
172+
<Users className="mr-2 h-4 w-4" />
173+
Members
174+
</TabsTrigger>
175+
<TabsTrigger value="invitations">
176+
<Mail className="mr-2 h-4 w-4" />
177+
Invitations
178+
</TabsTrigger>
179+
</TabsList>
180+
181+
{/* Members Tab */}
182+
<TabsContent value="members" className="space-y-4">
183+
<Card>
184+
<CardHeader>
185+
<div className="flex items-center justify-between">
107186
<div>
108-
<div className="font-medium">{m.user?.name || m.name || m.userId}</div>
109-
{m.user?.email && (
110-
<div className="text-[11px] text-muted-foreground">{m.user.email}</div>
111-
)}
187+
<CardTitle className="text-base">Members</CardTitle>
188+
<CardDescription>
189+
People with access to this organization
190+
</CardDescription>
112191
</div>
113-
<code className="text-[10px] text-muted-foreground">{m.role ?? 'member'}</code>
114-
</li>
115-
))}
116-
</ul>
117-
</CardContent>
118-
</Card>
192+
<Button size="sm" onClick={() => setInviteDialogOpen(true)}>
193+
<UserPlus className="mr-2 h-4 w-4" />
194+
Invite
195+
</Button>
196+
</div>
197+
</CardHeader>
198+
<CardContent>
199+
{loadingMembers && (
200+
<p className="text-xs text-muted-foreground">Loading members...</p>
201+
)}
202+
{!loadingMembers && members.length === 0 && (
203+
<div className="text-center py-8">
204+
<Users className="mx-auto h-12 w-12 text-muted-foreground/50" />
205+
<p className="mt-4 text-sm text-muted-foreground">
206+
No members yet. Invite someone to get started.
207+
</p>
208+
<Button
209+
size="sm"
210+
variant="outline"
211+
className="mt-4"
212+
onClick={() => setInviteDialogOpen(true)}
213+
>
214+
<UserPlus className="mr-2 h-4 w-4" />
215+
Invite member
216+
</Button>
217+
</div>
218+
)}
219+
{!loadingMembers && members.length > 0 && (
220+
<div className="divide-y">
221+
{members.map((m, i) => (
222+
<div
223+
key={m.id ?? i}
224+
className="flex items-center justify-between py-3"
225+
>
226+
<div className="flex-1">
227+
<div className="font-medium text-sm">
228+
{m.user?.name || m.name || m.userId}
229+
</div>
230+
{m.user?.email && (
231+
<div className="text-xs text-muted-foreground">
232+
{m.user.email}
233+
</div>
234+
)}
235+
</div>
236+
<div className="flex items-center gap-2">
237+
<Badge variant="secondary" className="text-xs">
238+
{m.role ?? 'member'}
239+
</Badge>
240+
<DropdownMenu>
241+
<DropdownMenuTrigger asChild>
242+
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
243+
<MoreVertical className="h-4 w-4" />
244+
</Button>
245+
</DropdownMenuTrigger>
246+
<DropdownMenuContent align="end">
247+
<DropdownMenuItem className="text-destructive">
248+
<Trash2 className="mr-2 h-4 w-4" />
249+
Remove member
250+
</DropdownMenuItem>
251+
</DropdownMenuContent>
252+
</DropdownMenu>
253+
</div>
254+
</div>
255+
))}
256+
</div>
257+
)}
258+
</CardContent>
259+
</Card>
260+
</TabsContent>
261+
262+
{/* Invitations Tab */}
263+
<TabsContent value="invitations" className="space-y-4">
264+
<Card>
265+
<CardHeader>
266+
<div className="flex items-center justify-between">
267+
<div>
268+
<CardTitle className="text-base">Pending Invitations</CardTitle>
269+
<CardDescription>
270+
Invitations sent to join this organization
271+
</CardDescription>
272+
</div>
273+
<Button size="sm" onClick={() => setInviteDialogOpen(true)}>
274+
<UserPlus className="mr-2 h-4 w-4" />
275+
Send invitation
276+
</Button>
277+
</div>
278+
</CardHeader>
279+
<CardContent>
280+
<div className="text-center py-8">
281+
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" />
282+
<p className="mt-4 text-sm text-muted-foreground">
283+
No pending invitations
284+
</p>
285+
<p className="text-xs text-muted-foreground mt-2">
286+
Invitations will appear here once sent
287+
</p>
288+
</div>
289+
</CardContent>
290+
</Card>
291+
</TabsContent>
292+
</Tabs>
293+
119294
<p className="text-xs text-muted-foreground">
120295
Need to manage environments?{' '}
121296
<Link to="/environments" className="text-primary hover:underline">
@@ -124,6 +299,61 @@ function OrgDetailPage() {
124299
</p>
125300
</div>
126301
</div>
302+
303+
{/* Invite Member Dialog */}
304+
<Dialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen}>
305+
<DialogContent>
306+
<DialogHeader>
307+
<DialogTitle>Invite member</DialogTitle>
308+
<DialogDescription>
309+
Send an invitation to join {org?.name}
310+
</DialogDescription>
311+
</DialogHeader>
312+
<div className="space-y-4 py-4">
313+
<div className="space-y-2">
314+
<Label htmlFor="email">Email address</Label>
315+
<Input
316+
id="email"
317+
type="email"
318+
placeholder="colleague@example.com"
319+
value={inviteEmail}
320+
onChange={(e) => setInviteEmail(e.target.value)}
321+
/>
322+
</div>
323+
<div className="space-y-2">
324+
<Label htmlFor="role">Role</Label>
325+
<Select value={inviteRole} onValueChange={setInviteRole}>
326+
<SelectTrigger id="role">
327+
<SelectValue />
328+
</SelectTrigger>
329+
<SelectContent>
330+
<SelectItem value="owner">Owner</SelectItem>
331+
<SelectItem value="admin">Admin</SelectItem>
332+
<SelectItem value="member">Member</SelectItem>
333+
</SelectContent>
334+
</Select>
335+
<p className="text-xs text-muted-foreground">
336+
{inviteRole === 'owner' && 'Full access to manage the organization'}
337+
{inviteRole === 'admin' &&
338+
'Can manage members and settings, but cannot delete the organization'}
339+
{inviteRole === 'member' && 'Can view and use organization resources'}
340+
</p>
341+
</div>
342+
</div>
343+
<DialogFooter>
344+
<Button
345+
variant="outline"
346+
onClick={() => setInviteDialogOpen(false)}
347+
disabled={inviting}
348+
>
349+
Cancel
350+
</Button>
351+
<Button onClick={handleInviteMember} disabled={inviting}>
352+
{inviting ? 'Sending...' : 'Send invitation'}
353+
</Button>
354+
</DialogFooter>
355+
</DialogContent>
356+
</Dialog>
127357
</div>
128358
);
129359
}

0 commit comments

Comments
 (0)