From ea2c47b53fde1862bb7669a4f6b3cfa682698bee Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 21:29:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(settings):=20Phase=202=20=E2=80=94=20all?= =?UTF-8?q?=2015=20tabs=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../settings/unified/UnifiedSettingsModal.tsx | 34 ++-- .../unified/tabs/AccountDevicesTab.tsx | 18 ++ .../unified/tabs/AccountSecurityTab.tsx | 16 ++ .../settings/unified/tabs/SiteBotSpamTab.tsx | 85 ++++++++++ .../unified/tabs/SiteIntegrationsTab.tsx | 130 +++++++++++++++ .../settings/unified/tabs/SitePrivacyTab.tsx | 111 +++++++++++++ .../settings/unified/tabs/SiteReportsTab.tsx | 157 ++++++++++++++++++ .../unified/tabs/SiteVisibilityTab.tsx | 131 +++++++++++++++ .../unified/tabs/WorkspaceAuditTab.tsx | 80 +++++++++ .../unified/tabs/WorkspaceMembersTab.tsx | 150 +++++++++++++++++ .../tabs/WorkspaceNotificationsTab.tsx | 68 ++++++++ 11 files changed, 969 insertions(+), 11 deletions(-) create mode 100644 components/settings/unified/tabs/AccountDevicesTab.tsx create mode 100644 components/settings/unified/tabs/AccountSecurityTab.tsx create mode 100644 components/settings/unified/tabs/SiteBotSpamTab.tsx create mode 100644 components/settings/unified/tabs/SiteIntegrationsTab.tsx create mode 100644 components/settings/unified/tabs/SitePrivacyTab.tsx create mode 100644 components/settings/unified/tabs/SiteReportsTab.tsx create mode 100644 components/settings/unified/tabs/SiteVisibilityTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceAuditTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceMembersTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceNotificationsTab.tsx 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)} /> +
+ ))} +
+ +
+ + + +
+
+ + {/* 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" + /> +
+