diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx
index 94e77a0..3ced645 100644
--- a/components/settings/unified/UnifiedSettingsModal.tsx
+++ b/components/settings/unified/UnifiedSettingsModal.tsx
@@ -8,12 +8,24 @@ import { useAuth } from '@/lib/auth/context'
import { useSite } from '@/lib/swr/dashboard'
import { listSites, type Site } from '@/lib/api/sites'
-// Tab content components
+// Tab content components — Site
import SiteGeneralTab from './tabs/SiteGeneralTab'
import SiteGoalsTab from './tabs/SiteGoalsTab'
+import SiteVisibilityTab from './tabs/SiteVisibilityTab'
+import SitePrivacyTab from './tabs/SitePrivacyTab'
+import SiteBotSpamTab from './tabs/SiteBotSpamTab'
+import SiteReportsTab from './tabs/SiteReportsTab'
+import SiteIntegrationsTab from './tabs/SiteIntegrationsTab'
+// Tab content components — Workspace
import WorkspaceGeneralTab from './tabs/WorkspaceGeneralTab'
import WorkspaceBillingTab from './tabs/WorkspaceBillingTab'
+import WorkspaceMembersTab from './tabs/WorkspaceMembersTab'
+import WorkspaceNotificationsTab from './tabs/WorkspaceNotificationsTab'
+import WorkspaceAuditTab from './tabs/WorkspaceAuditTab'
+// Tab content components — Account
import AccountProfileTab from './tabs/AccountProfileTab'
+import AccountSecurityTab from './tabs/AccountSecurityTab'
+import AccountDevicesTab from './tabs/AccountDevicesTab'
// ─── Types ──────────────────────────────────────────────────────
@@ -213,11 +225,11 @@ function TabContent({
switch (activeTab) {
case 'general': return
case 'goals': return
- case 'visibility': return
- case 'privacy': return
- case 'bot-spam': return
- case 'reports': return
- case 'integrations': return
+ case 'visibility': return
+ case 'privacy': return
+ case 'bot-spam': return
+ case 'reports': return
+ case 'integrations': return
}
}
@@ -226,9 +238,9 @@ function TabContent({
switch (activeTab) {
case 'general': return
case 'billing': return
- case 'members': return
- case 'notifications': return
- case 'audit': return
+ case 'members': return
+ case 'notifications': return
+ case 'audit': return
}
}
@@ -236,8 +248,8 @@ function TabContent({
if (context === 'account') {
switch (activeTab) {
case 'profile': return
- case 'security': return
- case 'devices': return
+ case 'security': return
+ case 'devices': return
}
}
diff --git a/components/settings/unified/tabs/AccountDevicesTab.tsx b/components/settings/unified/tabs/AccountDevicesTab.tsx
new file mode 100644
index 0000000..695b8f6
--- /dev/null
+++ b/components/settings/unified/tabs/AccountDevicesTab.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
+import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
+
+export default function AccountDevicesTab() {
+ return (
+
+
+
Devices & Activity
+
Manage trusted devices and review security activity.
+
+
+
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/AccountSecurityTab.tsx b/components/settings/unified/tabs/AccountSecurityTab.tsx
new file mode 100644
index 0000000..c3ef2dd
--- /dev/null
+++ b/components/settings/unified/tabs/AccountSecurityTab.tsx
@@ -0,0 +1,16 @@
+'use client'
+
+import ProfileSettings from '@/components/settings/ProfileSettings'
+
+export default function AccountSecurityTab() {
+ return (
+
+
+
Security
+
Manage your password and two-factor authentication.
+
+
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/SiteBotSpamTab.tsx b/components/settings/unified/tabs/SiteBotSpamTab.tsx
new file mode 100644
index 0000000..98a452d
--- /dev/null
+++ b/components/settings/unified/tabs/SiteBotSpamTab.tsx
@@ -0,0 +1,85 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button, Toggle, toast, Spinner } from '@ciphera-net/ui'
+import { ShieldCheck } from '@phosphor-icons/react'
+import { useSite, useBotFilterStats } from '@/lib/swr/dashboard'
+import { updateSite } from '@/lib/api/sites'
+
+export default function SiteBotSpamTab({ siteId }: { siteId: string }) {
+ const { data: site, mutate } = useSite(siteId)
+ const { data: botStats } = useBotFilterStats(siteId)
+ const [filterBots, setFilterBots] = useState(false)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (site) setFilterBots(site.filter_bots ?? false)
+ }, [site])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
+ await mutate()
+ toast.success('Bot filtering updated')
+ } catch {
+ toast.error('Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (!site) return
+
+ return (
+
+
+
Bot & Spam Filtering
+
Automatically filter bot traffic and referrer spam from your analytics.
+
+
+ {/* Bot filtering toggle */}
+
+
+
+
+
Enable bot filtering
+
Filter known bots, crawlers, referrer spam, and suspicious traffic.
+
+
+
setFilterBots(p => !p)} />
+
+
+ {/* Stats */}
+ {botStats && (
+
+
+
{botStats.filtered_sessions ?? 0}
+
Sessions filtered
+
+
+
{botStats.filtered_events ?? 0}
+
Events filtered
+
+
+
{botStats.auto_blocked_this_month ?? 0}
+
Auto-blocked this month
+
+
+ )}
+
+
+ For detailed session review and manual blocking, use the full{' '}
+
+ site settings page
+ .
+
+
+
+
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/SiteIntegrationsTab.tsx b/components/settings/unified/tabs/SiteIntegrationsTab.tsx
new file mode 100644
index 0000000..0165464
--- /dev/null
+++ b/components/settings/unified/tabs/SiteIntegrationsTab.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+import { Button, toast, Spinner } from '@ciphera-net/ui'
+import { GoogleLogo, ArrowSquareOut, Plugs, Trash } from '@phosphor-icons/react'
+import { useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard'
+import { disconnectGSC, getGSCAuthURL } from '@/lib/api/gsc'
+import { disconnectBunny } from '@/lib/api/bunny'
+import { getAuthErrorMessage } from '@ciphera-net/ui'
+
+function IntegrationCard({
+ icon,
+ name,
+ description,
+ connected,
+ detail,
+ onConnect,
+ onDisconnect,
+ connectLabel = 'Connect',
+}: {
+ icon: React.ReactNode
+ name: string
+ description: string
+ connected: boolean
+ detail?: string
+ onConnect: () => void
+ onDisconnect: () => void
+ connectLabel?: string
+}) {
+ return (
+
+
+
{icon}
+
+
+
{name}
+ {connected && (
+
+
+ Connected
+
+ )}
+
+
{detail || description}
+
+
+ {connected ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default function SiteIntegrationsTab({ siteId }: { siteId: string }) {
+ const { data: gscStatus, mutate: mutateGSC } = useGSCStatus(siteId)
+ const { data: bunnyStatus, mutate: mutateBunny } = useBunnyStatus(siteId)
+
+ const handleConnectGSC = async () => {
+ try {
+ const data = await getGSCAuthURL(siteId)
+ window.open(data.auth_url, '_blank')
+ } catch (err) {
+ toast.error(getAuthErrorMessage(err as Error) || 'Failed to start Google authorization')
+ }
+ }
+
+ const handleDisconnectGSC = async () => {
+ if (!confirm('Disconnect Google Search Console? This will remove all synced data.')) return
+ try {
+ await disconnectGSC(siteId)
+ await mutateGSC()
+ toast.success('Google Search Console disconnected')
+ } catch (err) {
+ toast.error(getAuthErrorMessage(err as Error) || 'Failed to disconnect')
+ }
+ }
+
+ const handleConnectBunny = () => {
+ // Redirect to full settings page for BunnyCDN setup (requires API key input)
+ window.location.href = `/sites/${siteId}/settings?tab=integrations`
+ }
+
+ const handleDisconnectBunny = async () => {
+ if (!confirm('Disconnect BunnyCDN? This will remove all synced CDN data.')) return
+ try {
+ await disconnectBunny(siteId)
+ await mutateBunny()
+ toast.success('BunnyCDN disconnected')
+ } catch (err) {
+ toast.error(getAuthErrorMessage(err as Error) || 'Failed to disconnect')
+ }
+ }
+
+ return (
+
+
+
Integrations
+
Connect third-party services to enrich your analytics.
+
+
+
+ }
+ name="Google Search Console"
+ description="View search queries, clicks, impressions, and ranking data."
+ connected={gscStatus?.connected ?? false}
+ detail={gscStatus?.connected ? `Connected as ${gscStatus.google_email || 'unknown'}` : undefined}
+ onConnect={handleConnectGSC}
+ onDisconnect={handleDisconnectGSC}
+ connectLabel="Connect with Google"
+ />
+
+ { (e.target as HTMLImageElement).style.display = 'none' }} />}
+ name="BunnyCDN"
+ description="Monitor bandwidth, cache hit rates, and CDN performance."
+ connected={bunnyStatus?.connected ?? false}
+ detail={bunnyStatus?.connected ? `Pull zone: ${bunnyStatus.pull_zone_name || 'connected'}` : undefined}
+ onConnect={handleConnectBunny}
+ onDisconnect={handleDisconnectBunny}
+ />
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/SitePrivacyTab.tsx b/components/settings/unified/tabs/SitePrivacyTab.tsx
new file mode 100644
index 0000000..1f1e2b2
--- /dev/null
+++ b/components/settings/unified/tabs/SitePrivacyTab.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
+import { useSite } from '@/lib/swr/dashboard'
+import { updateSite } from '@/lib/api/sites'
+
+const GEO_OPTIONS = [
+ { value: 'full', label: 'Full (country, region, city)' },
+ { value: 'country', label: 'Country only' },
+ { value: 'none', label: 'Disabled' },
+]
+
+export default function SitePrivacyTab({ siteId }: { siteId: string }) {
+ const { data: site, mutate } = useSite(siteId)
+ const [collectPagePaths, setCollectPagePaths] = useState(true)
+ const [collectReferrers, setCollectReferrers] = useState(true)
+ const [collectDeviceInfo, setCollectDeviceInfo] = useState(true)
+ const [collectScreenRes, setCollectScreenRes] = useState(true)
+ const [collectGeoData, setCollectGeoData] = useState('full')
+ const [hideUnknownLocations, setHideUnknownLocations] = useState(false)
+ const [dataRetention, setDataRetention] = useState(6)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (site) {
+ setCollectPagePaths(site.collect_page_paths ?? true)
+ setCollectReferrers(site.collect_referrers ?? true)
+ setCollectDeviceInfo(site.collect_device_info ?? true)
+ setCollectScreenRes(site.collect_screen_resolution ?? true)
+ setCollectGeoData(site.collect_geo_data ?? 'full')
+ setHideUnknownLocations(site.hide_unknown_locations ?? false)
+ setDataRetention(site.data_retention_months ?? 6)
+ }
+ }, [site])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await updateSite(siteId, {
+ name: site?.name || '',
+ collect_page_paths: collectPagePaths,
+ collect_referrers: collectReferrers,
+ collect_device_info: collectDeviceInfo,
+ collect_screen_resolution: collectScreenRes,
+ collect_geo_data: collectGeoData as 'full' | 'country' | 'none',
+ hide_unknown_locations: hideUnknownLocations,
+ })
+ await mutate()
+ toast.success('Privacy settings updated')
+ } catch {
+ toast.error('Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (!site) return
+
+ return (
+
+
+
Data & Privacy
+
Control what data is collected from your visitors.
+
+
+
+ {[
+ { label: 'Page paths', desc: 'Track which pages visitors view.', checked: collectPagePaths, onChange: setCollectPagePaths },
+ { label: 'Referrers', desc: 'Track where visitors come from.', checked: collectReferrers, onChange: setCollectReferrers },
+ { label: 'Device info', desc: 'Track browser, OS, and device type.', checked: collectDeviceInfo, onChange: setCollectDeviceInfo },
+ { label: 'Screen resolution', desc: 'Track visitor screen dimensions.', checked: collectScreenRes, onChange: setCollectScreenRes },
+ { label: 'Hide unknown locations', desc: 'Exclude "Unknown" from location stats.', checked: hideUnknownLocations, onChange: setHideUnknownLocations },
+ ].map(item => (
+
+
+
{item.label}
+
{item.desc}
+
+
item.onChange((p: boolean) => !p)} />
+
+ ))}
+
+
+
+
+
+
Controls location granularity. "Disabled" collects no geographic data at all.
+
+
+
+
+
+ Currently retaining data for {dataRetention} months.
+ Manage retention in the full site settings.
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/SiteReportsTab.tsx b/components/settings/unified/tabs/SiteReportsTab.tsx
new file mode 100644
index 0000000..d8852eb
--- /dev/null
+++ b/components/settings/unified/tabs/SiteReportsTab.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import { useState } from 'react'
+import { Button, toast, Spinner } from '@ciphera-net/ui'
+import { Plus, Pencil, Trash, EnvelopeSimple, WebhooksLogo, PaperPlaneTilt } from '@phosphor-icons/react'
+import { useReportSchedules, useAlertSchedules } from '@/lib/swr/dashboard'
+import { deleteReportSchedule, testReportSchedule, updateReportSchedule, type ReportSchedule } from '@/lib/api/report-schedules'
+import { getAuthErrorMessage } from '@ciphera-net/ui'
+
+function ChannelIcon({ channel }: { channel: string }) {
+ switch (channel) {
+ case 'email': return
+ case 'webhook': return
+ default: return
+ }
+}
+
+function ScheduleRow({ schedule, siteId, onMutate }: { schedule: ReportSchedule; siteId: string; onMutate: () => void }) {
+ const [testing, setTesting] = useState(false)
+
+ const handleTest = async () => {
+ setTesting(true)
+ try {
+ await testReportSchedule(siteId, schedule.id)
+ toast.success('Test report sent')
+ } catch (err) {
+ toast.error(getAuthErrorMessage(err as Error) || 'Failed to send test')
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ const handleToggle = async () => {
+ try {
+ await updateReportSchedule(siteId, schedule.id, {
+ channel: schedule.channel,
+ channel_config: schedule.channel_config,
+ frequency: schedule.frequency,
+ report_type: schedule.report_type,
+ enabled: !schedule.enabled,
+ send_hour: schedule.send_hour,
+ send_day: schedule.send_day ?? undefined,
+ timezone: schedule.timezone,
+ purpose: schedule.purpose,
+ })
+ toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
+ onMutate()
+ } catch (err) {
+ toast.error(getAuthErrorMessage(err as Error) || 'Failed to update')
+ }
+ }
+
+ const handleDelete = async () => {
+ try {
+ await deleteReportSchedule(siteId, schedule.id)
+ toast.success('Report deleted')
+ onMutate()
+ } catch (err) {
+ toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete')
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {schedule.channel === 'email' && 'recipients' in schedule.channel_config
+ ? (schedule.channel_config as { recipients: string[] }).recipients?.[0]
+ : schedule.channel}
+ {!schedule.enabled && (paused)}
+
+
+ {schedule.frequency} · {schedule.report_type} report
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function SiteReportsTab({ siteId }: { siteId: string }) {
+ const { data: reports = [], isLoading: reportsLoading, mutate: mutateReports } = useReportSchedules(siteId)
+ const { data: alerts = [], isLoading: alertsLoading, mutate: mutateAlerts } = useAlertSchedules(siteId)
+
+ const loading = reportsLoading || alertsLoading
+
+ if (loading) return
+
+ return (
+
+ {/* Scheduled Reports */}
+
+
+
+
Scheduled Reports
+
Automated analytics summaries via email or webhook.
+
+
+
+
+
+
+ {reports.length === 0 ? (
+
No scheduled reports yet.
+ ) : (
+
+ {reports.map(r => (
+ mutateReports()} />
+ ))}
+
+ )}
+
+
+ {/* Alert Channels */}
+
+
+
+ {alerts.length === 0 ? (
+
No alert channels configured.
+ ) : (
+
+ {alerts.map(a => (
+ mutateAlerts()} />
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/SiteVisibilityTab.tsx b/components/settings/unified/tabs/SiteVisibilityTab.tsx
new file mode 100644
index 0000000..f6f3bd1
--- /dev/null
+++ b/components/settings/unified/tabs/SiteVisibilityTab.tsx
@@ -0,0 +1,131 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui'
+import { Copy, CheckCircle, Lock } from '@phosphor-icons/react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { useSite } from '@/lib/swr/dashboard'
+import { updateSite } from '@/lib/api/sites'
+
+const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
+
+export default function SiteVisibilityTab({ siteId }: { siteId: string }) {
+ const { data: site, mutate } = useSite(siteId)
+ const [isPublic, setIsPublic] = useState(false)
+ const [password, setPassword] = useState('')
+ const [passwordEnabled, setPasswordEnabled] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [linkCopied, setLinkCopied] = useState(false)
+
+ useEffect(() => {
+ if (site) {
+ setIsPublic(site.is_public ?? false)
+ setPasswordEnabled(site.has_password ?? false)
+ }
+ }, [site])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await updateSite(siteId, {
+ name: site?.name || '',
+ is_public: isPublic,
+ password: passwordEnabled ? password : undefined,
+ clear_password: !passwordEnabled,
+ })
+ setPassword('')
+ await mutate()
+ toast.success('Visibility updated')
+ } catch {
+ toast.error('Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const copyLink = () => {
+ navigator.clipboard.writeText(`${APP_URL}/share/${siteId}`)
+ setLinkCopied(true)
+ toast.success('Link copied')
+ setTimeout(() => setLinkCopied(false), 2000)
+ }
+
+ if (!site) return
+
+ return (
+
+
+
Visibility
+
Control who can see your analytics dashboard.
+
+
+ {/* Public toggle */}
+
+
+
Public Dashboard
+
Allow anyone with the link to view this dashboard.
+
+
setIsPublic(p => !p)} />
+
+
+
+ {isPublic && (
+
+ {/* Share link */}
+
+
+
+
+
+
+
+
+ {/* Password protection */}
+
+
+
+
+
Password Protection
+
Require a password to view the public dashboard.
+
+
+
setPasswordEnabled(p => !p)} />
+
+
+
+ {passwordEnabled && (
+
+ setPassword(e.target.value)}
+ placeholder={site.has_password ? 'Leave empty to keep current password' : 'Set a password'}
+ />
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/WorkspaceAuditTab.tsx b/components/settings/unified/tabs/WorkspaceAuditTab.tsx
new file mode 100644
index 0000000..6f29469
--- /dev/null
+++ b/components/settings/unified/tabs/WorkspaceAuditTab.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Spinner } from '@ciphera-net/ui'
+import { useAuth } from '@/lib/auth/context'
+import { getAuditLog, type AuditLogEntry } from '@/lib/api/audit'
+import { formatDateTimeShort } from '@/lib/utils/formatDate'
+
+const ACTION_LABELS: Record = {
+ site_created: 'Created site',
+ site_updated: 'Updated site',
+ site_deleted: 'Deleted site',
+ site_restored: 'Restored site',
+ goal_created: 'Created goal',
+ goal_updated: 'Updated goal',
+ goal_deleted: 'Deleted goal',
+ funnel_created: 'Created funnel',
+ funnel_updated: 'Updated funnel',
+ funnel_deleted: 'Deleted funnel',
+ gsc_connected: 'Connected Google Search Console',
+ gsc_disconnected: 'Disconnected Google Search Console',
+ bunny_connected: 'Connected BunnyCDN',
+ bunny_disconnected: 'Disconnected BunnyCDN',
+ member_invited: 'Invited member',
+ member_removed: 'Removed member',
+ member_role_changed: 'Changed member role',
+ org_updated: 'Updated organization',
+ plan_changed: 'Changed plan',
+ subscription_cancelled: 'Cancelled subscription',
+ subscription_resumed: 'Resumed subscription',
+}
+
+export default function WorkspaceAuditTab() {
+ const { user } = useAuth()
+ const [entries, setEntries] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ if (!user?.org_id) return
+ getAuditLog({ limit: 50 })
+ .then(data => setEntries(data.entries))
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }, [user?.org_id])
+
+ if (loading) return
+
+ return (
+
+
+
Audit Log
+
Track who made changes and when.
+
+
+ {entries.length === 0 ? (
+
No activity recorded yet.
+ ) : (
+
+ {entries.map(entry => (
+
+
+
+ {entry.actor_email || 'System'}
+ {' '}
+ {ACTION_LABELS[entry.action] || entry.action}
+
+ {entry.payload && Object.keys(entry.payload).length > 0 && (
+
{JSON.stringify(entry.payload)}
+ )}
+
+
+ {formatDateTimeShort(new Date(entry.occurred_at))}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/components/settings/unified/tabs/WorkspaceMembersTab.tsx b/components/settings/unified/tabs/WorkspaceMembersTab.tsx
new file mode 100644
index 0000000..24e7729
--- /dev/null
+++ b/components/settings/unified/tabs/WorkspaceMembersTab.tsx
@@ -0,0 +1,150 @@
+'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 (
+
+ Owner
+
+ )
+ if (role === 'admin') return (
+
+ Admin
+
+ )
+ return (
+
+ Member
+
+ )
+}
+
+export default function WorkspaceMembersTab() {
+ const { user } = useAuth()
+ const [members, setMembers] = useState([])
+ 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
+
+ return (
+
+
+
+
Members
+
{members.length} member{members.length !== 1 ? 's' : ''} in your workspace.
+
+ {canManage && !showInvite && (
+
+ )}
+
+
+ {/* Invite form */}
+ {showInvite && (
+
+
+
+ setInviteEmail(e.target.value)}
+ placeholder="email@example.com"
+ type="email"
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Members list */}
+
+ {members.map(member => (
+
+
+
+
+
{member.user_email || member.user_id}
+
+
+
+
+ {canManage && member.role !== 'owner' && member.user_id !== user?.id && (
+
+ )}
+
+
+ ))}
+
+
+ )
+}
diff --git a/components/settings/unified/tabs/WorkspaceNotificationsTab.tsx b/components/settings/unified/tabs/WorkspaceNotificationsTab.tsx
new file mode 100644
index 0000000..154820e
--- /dev/null
+++ b/components/settings/unified/tabs/WorkspaceNotificationsTab.tsx
@@ -0,0 +1,68 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { toast, Spinner } from '@ciphera-net/ui'
+import { useAuth } from '@/lib/auth/context'
+import { getNotificationSettings, updateNotificationSettings, type NotificationSettingsResponse } from '@/lib/api/notification-settings'
+
+export default function WorkspaceNotificationsTab() {
+ const { user } = useAuth()
+ const [data, setData] = useState(null)
+ const [settings, setSettings] = useState>({})
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ if (!user?.org_id) return
+ getNotificationSettings()
+ .then(resp => {
+ setData(resp)
+ setSettings(resp.settings || {})
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }, [user?.org_id])
+
+ const handleToggle = async (key: string) => {
+ const prev = { ...settings }
+ const updated = { ...settings, [key]: !settings[key] }
+ setSettings(updated)
+ try {
+ await updateNotificationSettings(updated)
+ } catch {
+ setSettings(prev)
+ toast.error('Failed to update notification preference')
+ }
+ }
+
+ if (loading) return
+
+ return (
+
+
+
Notifications
+
Choose what notifications you receive.
+
+
+
+ {(data?.categories || []).map(cat => (
+
+
+
{cat.label}
+
{cat.description}
+
+
+
+ ))}
+
+ {(!data?.categories || data.categories.length === 0) && (
+
No notification preferences available.
+ )}
+
+
+ )
+}