22
33import { createFileRoute , Link , useNavigate } from '@tanstack/react-router' ;
44import { useEffect , useState } from 'react' ;
5- import { ArrowLeft } from 'lucide-react' ;
5+ import { ArrowLeft , Mail , MoreVertical , Trash2 , UserPlus , Users } from 'lucide-react' ;
66import { useClient } from '@objectstack/client-react' ;
77import { Button } from '@/components/ui/button' ;
88import { 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' ;
934import { toast } from '@/hooks/use-toast' ;
1035import { 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