Files
pulse/components/settings/unified/tabs/WorkspaceMembersTab.tsx
Usman Baig ea2c47b53f feat(settings): Phase 2 — all 15 tabs implemented
Site tabs:
- Visibility (public toggle, share link, password protection)
- Privacy (data collection toggles, geo level, retention info)
- Bot & Spam (filtering toggle, stats cards)
- Reports (scheduled reports + alert channels list with test/pause/delete)
- Integrations (GSC + BunnyCDN connect/disconnect cards)

Workspace tabs:
- Members (member list, invite form with role selector)
- Notifications (dynamic toggles from API categories)
- Audit Log (action log with timestamps)

Account tabs:
- Security (wraps existing ProfileSettings security tab)
- Devices (wraps existing TrustedDevicesCard + SecurityActivityCard)

No more "Coming soon" placeholders. All tabs are functional.
2026-03-23 21:29:49 +01:00

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 workspace.</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>
)
}