- Site context is locked to the site from the current URL — no dropdown switcher. If not on a site page, defaults to Organization context. - Renamed "Workspace" to "Organization" in all user-facing text. - Removed unused CaretDown import and dropdown state.
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Button, Input, Select, toast, Spinner } from '@ciphera-net/ui'
|
|
import { Plus, Trash, EnvelopeSimple, Crown, UserCircle } from '@phosphor-icons/react'
|
|
import { useAuth } from '@/lib/auth/context'
|
|
import { getOrganizationMembers, sendInvitation, type OrganizationMember } from '@/lib/api/organization'
|
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
|
|
|
const ROLE_OPTIONS = [
|
|
{ value: 'admin', label: 'Admin' },
|
|
{ value: 'member', label: 'Member' },
|
|
]
|
|
|
|
function RoleBadge({ role }: { role: string }) {
|
|
if (role === 'owner') return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-brand-orange/10 text-brand-orange">
|
|
<Crown weight="bold" className="w-3 h-3" /> Owner
|
|
</span>
|
|
)
|
|
if (role === 'admin') return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-900/30 text-blue-400">
|
|
Admin
|
|
</span>
|
|
)
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-neutral-800 text-neutral-400">
|
|
Member
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export default function WorkspaceMembersTab() {
|
|
const { user } = useAuth()
|
|
const [members, setMembers] = useState<OrganizationMember[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [inviteEmail, setInviteEmail] = useState('')
|
|
const [inviteRole, setInviteRole] = useState('member')
|
|
const [inviting, setInviting] = useState(false)
|
|
const [showInvite, setShowInvite] = useState(false)
|
|
|
|
const canManage = user?.role === 'owner' || user?.role === 'admin'
|
|
|
|
const loadMembers = async () => {
|
|
if (!user?.org_id) return
|
|
try {
|
|
const data = await getOrganizationMembers(user.org_id)
|
|
setMembers(data)
|
|
} catch { }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
useEffect(() => { loadMembers() }, [user?.org_id])
|
|
|
|
const handleInvite = async () => {
|
|
if (!user?.org_id || !inviteEmail.trim()) return
|
|
setInviting(true)
|
|
try {
|
|
await sendInvitation(user.org_id, inviteEmail.trim(), inviteRole)
|
|
toast.success(`Invitation sent to ${inviteEmail}`)
|
|
setInviteEmail('')
|
|
setShowInvite(false)
|
|
loadMembers()
|
|
} catch (err) {
|
|
toast.error(getAuthErrorMessage(err as Error) || 'Failed to invite member')
|
|
} finally {
|
|
setInviting(false)
|
|
}
|
|
}
|
|
|
|
const handleRemove = async (_memberId: string, email: string) => {
|
|
// Member removal requires the full org settings page (auth API endpoint)
|
|
toast.message(`To remove ${email}, use Organization Settings → Members.`, {
|
|
action: { label: 'Open', onClick: () => { window.location.href = '/org-settings?tab=members' } },
|
|
})
|
|
}
|
|
|
|
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-white mb-1">Members</h3>
|
|
<p className="text-sm text-neutral-400">{members.length} member{members.length !== 1 ? 's' : ''} in your organization.</p>
|
|
</div>
|
|
{canManage && !showInvite && (
|
|
<Button onClick={() => setShowInvite(true)} variant="primary" className="text-sm gap-1.5">
|
|
<Plus weight="bold" className="w-3.5 h-3.5" /> Invite
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Invite form */}
|
|
{showInvite && (
|
|
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 space-y-3">
|
|
<div className="flex gap-3">
|
|
<div className="flex-1">
|
|
<Input
|
|
value={inviteEmail}
|
|
onChange={e => setInviteEmail(e.target.value)}
|
|
placeholder="email@example.com"
|
|
type="email"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={inviteRole}
|
|
onChange={setInviteRole}
|
|
variant="input"
|
|
className="w-32"
|
|
options={ROLE_OPTIONS}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button onClick={() => setShowInvite(false)} variant="secondary" className="text-sm">Cancel</Button>
|
|
<Button onClick={handleInvite} variant="primary" className="text-sm gap-1.5" disabled={inviting}>
|
|
<EnvelopeSimple weight="bold" className="w-3.5 h-3.5" />
|
|
{inviting ? 'Sending...' : 'Send Invite'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Members list */}
|
|
<div className="space-y-1">
|
|
{members.map(member => (
|
|
<div key={member.user_id} className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors group">
|
|
<div className="flex items-center gap-3">
|
|
<UserCircle weight="fill" className="w-8 h-8 text-neutral-600" />
|
|
<div>
|
|
<p className="text-sm font-medium text-white">{member.user_email || member.user_id}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<RoleBadge role={member.role} />
|
|
{canManage && member.role !== 'owner' && member.user_id !== user?.id && (
|
|
<button
|
|
onClick={() => handleRemove(member.user_id, member.user_email || member.user_id)}
|
|
className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors opacity-0 group-hover:opacity-100"
|
|
>
|
|
<Trash weight="bold" className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|