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.
This commit is contained in:
Usman Baig
2026-03-23 21:29:49 +01:00
parent e55a3c4ce4
commit ea2c47b53f
11 changed files with 969 additions and 11 deletions

View File

@@ -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 <SiteGeneralTab siteId={siteId} />
case 'goals': return <SiteGoalsTab siteId={siteId} />
case 'visibility': return <ComingSoon label="Visibility" />
case 'privacy': return <ComingSoon label="Data & Privacy" />
case 'bot-spam': return <ComingSoon label="Bot & Spam" />
case 'reports': return <ComingSoon label="Reports" />
case 'integrations': return <ComingSoon label="Integrations" />
case 'visibility': return <SiteVisibilityTab siteId={siteId} />
case 'privacy': return <SitePrivacyTab siteId={siteId} />
case 'bot-spam': return <SiteBotSpamTab siteId={siteId} />
case 'reports': return <SiteReportsTab siteId={siteId} />
case 'integrations': return <SiteIntegrationsTab siteId={siteId} />
}
}
@@ -226,9 +238,9 @@ function TabContent({
switch (activeTab) {
case 'general': return <WorkspaceGeneralTab />
case 'billing': return <WorkspaceBillingTab />
case 'members': return <ComingSoon label="Members" />
case 'notifications': return <ComingSoon label="Notifications" />
case 'audit': return <ComingSoon label="Audit Log" />
case 'members': return <WorkspaceMembersTab />
case 'notifications': return <WorkspaceNotificationsTab />
case 'audit': return <WorkspaceAuditTab />
}
}
@@ -236,8 +248,8 @@ function TabContent({
if (context === 'account') {
switch (activeTab) {
case 'profile': return <AccountProfileTab />
case 'security': return <ComingSoon label="Security" />
case 'devices': return <ComingSoon label="Devices" />
case 'security': return <AccountSecurityTab />
case 'devices': return <AccountDevicesTab />
}
}

View File

@@ -0,0 +1,18 @@
'use client'
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
export default function AccountDevicesTab() {
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Devices & Activity</h3>
<p className="text-sm text-neutral-400">Manage trusted devices and review security activity.</p>
</div>
<TrustedDevicesCard />
<SecurityActivityCard />
</div>
)
}

View File

@@ -0,0 +1,16 @@
'use client'
import ProfileSettings from '@/components/settings/ProfileSettings'
export default function AccountSecurityTab() {
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Security</h3>
<p className="text-sm text-neutral-400">Manage your password and two-factor authentication.</p>
</div>
<ProfileSettings activeTab="security" borderless />
</div>
)
}

View File

@@ -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 <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>
<h3 className="text-base font-semibold text-white mb-1">Bot & Spam Filtering</h3>
<p className="text-sm text-neutral-400">Automatically filter bot traffic and referrer spam from your analytics.</p>
</div>
{/* Bot filtering toggle */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div className="flex items-center gap-3">
<ShieldCheck weight="bold" className="w-5 h-5 text-brand-orange" />
<div>
<p className="text-sm font-medium text-white">Enable bot filtering</p>
<p className="text-xs text-neutral-400">Filter known bots, crawlers, referrer spam, and suspicious traffic.</p>
</div>
</div>
<Toggle checked={filterBots} onChange={() => setFilterBots(p => !p)} />
</div>
{/* Stats */}
{botStats && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
<p className="text-2xl font-bold text-white">{botStats.filtered_sessions ?? 0}</p>
<p className="text-xs text-neutral-400 mt-1">Sessions filtered</p>
</div>
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
<p className="text-2xl font-bold text-white">{botStats.filtered_events ?? 0}</p>
<p className="text-xs text-neutral-400 mt-1">Events filtered</p>
</div>
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
<p className="text-2xl font-bold text-white">{botStats.auto_blocked_this_month ?? 0}</p>
<p className="text-xs text-neutral-400 mt-1">Auto-blocked this month</p>
</div>
</div>
)}
<p className="text-sm text-neutral-400">
For detailed session review and manual blocking, use the full{' '}
<a href={`/sites/${siteId}/settings?tab=bot-spam`} className="text-brand-orange hover:underline">
site settings page
</a>.
</p>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex items-center justify-between py-4 px-4 rounded-xl border border-neutral-800 bg-neutral-800/20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-neutral-800">{icon}</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white">{name}</p>
{connected && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/30 text-green-400 border border-green-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
Connected
</span>
)}
</div>
<p className="text-xs text-neutral-400">{detail || description}</p>
</div>
</div>
{connected ? (
<Button onClick={onDisconnect} variant="secondary" className="text-sm text-red-400 border-red-900/50 hover:bg-red-900/20 gap-1.5">
<Trash weight="bold" className="w-3.5 h-3.5" /> Disconnect
</Button>
) : (
<Button onClick={onConnect} variant="primary" className="text-sm gap-1.5">
<Plugs weight="bold" className="w-3.5 h-3.5" /> {connectLabel}
</Button>
)}
</div>
)
}
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 (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Integrations</h3>
<p className="text-sm text-neutral-400">Connect third-party services to enrich your analytics.</p>
</div>
<div className="space-y-3">
<IntegrationCard
icon={<GoogleLogo weight="bold" className="w-5 h-5 text-white" />}
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"
/>
<IntegrationCard
icon={<img src="https://ciphera.net/bunny-icon.svg" alt="BunnyCDN" className="w-5 h-5" onError={e => { (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}
/>
</div>
</div>
)
}

View File

@@ -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 <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>
<h3 className="text-base font-semibold text-white mb-1">Data & Privacy</h3>
<p className="text-sm text-neutral-400">Control what data is collected from your visitors.</p>
</div>
<div className="space-y-1">
{[
{ 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 => (
<div key={item.label} className="flex items-center justify-between py-3 px-4 rounded-xl hover:bg-neutral-800/20 transition-colors">
<div>
<p className="text-sm font-medium text-white">{item.label}</p>
<p className="text-xs text-neutral-400">{item.desc}</p>
</div>
<Toggle checked={item.checked} onChange={() => item.onChange((p: boolean) => !p)} />
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Geographic data</label>
<Select
value={collectGeoData}
onChange={setCollectGeoData}
variant="input"
options={GEO_OPTIONS}
/>
<p className="text-xs text-neutral-500 mt-1">Controls location granularity. "Disabled" collects no geographic data at all.</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Data retention</label>
<p className="text-sm text-neutral-400">
Currently retaining data for <span className="font-medium text-white">{dataRetention} months</span>.
Manage retention in the full <a href={`/sites/${siteId}/settings`} className="text-brand-orange hover:underline">site settings</a>.
</p>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)
}

View File

@@ -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 <EnvelopeSimple weight="bold" className="w-4 h-4" />
case 'webhook': return <WebhooksLogo weight="bold" className="w-4 h-4" />
default: return <PaperPlaneTilt weight="bold" className="w-4 h-4" />
}
}
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 (
<div 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">
<div className={`p-1.5 rounded-lg ${schedule.enabled ? 'bg-brand-orange/10 text-brand-orange' : 'bg-neutral-800 text-neutral-500'}`}>
<ChannelIcon channel={schedule.channel} />
</div>
<div>
<p className="text-sm font-medium text-white">
{schedule.channel === 'email' && 'recipients' in schedule.channel_config
? (schedule.channel_config as { recipients: string[] }).recipients?.[0]
: schedule.channel}
{!schedule.enabled && <span className="ml-2 text-xs text-neutral-500">(paused)</span>}
</p>
<p className="text-xs text-neutral-500">
{schedule.frequency} · {schedule.report_type} report
</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={handleTest} disabled={testing} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors">
<PaperPlaneTilt weight="bold" className="w-3.5 h-3.5" />
</button>
<button onClick={handleToggle} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors">
{schedule.enabled ? 'Pause' : 'Enable'}
</button>
<button onClick={handleDelete} className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors">
<Trash weight="bold" className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
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 <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-8">
{/* Scheduled Reports */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-white mb-1">Scheduled Reports</h3>
<p className="text-sm text-neutral-400">Automated analytics summaries via email or webhook.</p>
</div>
<a href={`/sites/${siteId}/settings?tab=notifications`}>
<Button variant="primary" className="text-sm gap-1.5">
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Report
</Button>
</a>
</div>
{reports.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-6">No scheduled reports yet.</p>
) : (
<div className="space-y-1">
{reports.map(r => (
<ScheduleRow key={r.id} schedule={r} siteId={siteId} onMutate={() => mutateReports()} />
))}
</div>
)}
</div>
{/* Alert Channels */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-white mb-1">Alert Channels</h3>
<p className="text-sm text-neutral-400">Get notified when uptime monitors go down.</p>
</div>
<a href={`/sites/${siteId}/settings?tab=notifications`}>
<Button variant="secondary" className="text-sm gap-1.5">
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Channel
</Button>
</a>
</div>
{alerts.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-6">No alert channels configured.</p>
) : (
<div className="space-y-1">
{alerts.map(a => (
<ScheduleRow key={a.id} schedule={a} siteId={siteId} onMutate={() => mutateAlerts()} />
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -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 <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>
<h3 className="text-base font-semibold text-white mb-1">Visibility</h3>
<p className="text-sm text-neutral-400">Control who can see your analytics dashboard.</p>
</div>
{/* Public toggle */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div>
<p className="text-sm font-medium text-white">Public Dashboard</p>
<p className="text-xs text-neutral-400">Allow anyone with the link to view this dashboard.</p>
</div>
<Toggle checked={isPublic} onChange={() => setIsPublic(p => !p)} />
</div>
<AnimatePresence>
{isPublic && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 overflow-hidden"
>
{/* Share link */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Public Link</label>
<div className="flex gap-2">
<Input value={`${APP_URL}/share/${siteId}`} readOnly className="font-mono text-xs" />
<Button onClick={copyLink} variant="secondary" className="shrink-0 text-sm gap-1.5">
{linkCopied ? <CheckCircle weight="bold" className="w-3.5 h-3.5" /> : <Copy weight="bold" className="w-3.5 h-3.5" />}
{linkCopied ? 'Copied' : 'Copy'}
</Button>
</div>
</div>
{/* Password protection */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div className="flex items-center gap-2">
<Lock weight="bold" className="w-4 h-4 text-neutral-500" />
<div>
<p className="text-sm font-medium text-white">Password Protection</p>
<p className="text-xs text-neutral-400">Require a password to view the public dashboard.</p>
</div>
</div>
<Toggle checked={passwordEnabled} onChange={() => setPasswordEnabled(p => !p)} />
</div>
<AnimatePresence>
{passwordEnabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<Input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={site.has_password ? 'Leave empty to keep current password' : 'Set a password'}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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<AuditLogEntry[]>([])
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 <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>
<h3 className="text-base font-semibold text-white mb-1">Audit Log</h3>
<p className="text-sm text-neutral-400">Track who made changes and when.</p>
</div>
{entries.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-8">No activity recorded yet.</p>
) : (
<div className="space-y-0.5">
{entries.map(entry => (
<div key={entry.id} className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/20 transition-colors">
<div>
<p className="text-sm text-white">
<span className="font-medium">{entry.actor_email || 'System'}</span>
{' '}
<span className="text-neutral-400">{ACTION_LABELS[entry.action] || entry.action}</span>
</p>
{entry.payload && Object.keys(entry.payload).length > 0 && (
<p className="text-xs text-neutral-500 mt-0.5">{JSON.stringify(entry.payload)}</p>
)}
</div>
<p className="text-xs text-neutral-500 shrink-0 ml-4">
{formatDateTimeShort(new Date(entry.occurred_at))}
</p>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -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 (
<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>
)
}

View File

@@ -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<NotificationSettingsResponse | null>(null)
const [settings, setSettings] = useState<Record<string, boolean>>({})
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 <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>
<h3 className="text-base font-semibold text-white mb-1">Notifications</h3>
<p className="text-sm text-neutral-400">Choose what notifications you receive.</p>
</div>
<div className="space-y-1">
{(data?.categories || []).map(cat => (
<div key={cat.id} className="flex items-center justify-between py-3 px-4 rounded-xl hover:bg-neutral-800/20 transition-colors">
<div>
<p className="text-sm font-medium text-white">{cat.label}</p>
<p className="text-xs text-neutral-400">{cat.description}</p>
</div>
<button
onClick={() => handleToggle(cat.id)}
className={`relative w-10 h-6 rounded-full transition-colors duration-200 ${settings[cat.id] ? 'bg-brand-orange' : 'bg-neutral-700'}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${settings[cat.id] ? 'translate-x-4' : ''}`} />
</button>
</div>
))}
{(!data?.categories || data.categories.length === 0) && (
<p className="text-sm text-neutral-500 text-center py-6">No notification preferences available.</p>
)}
</div>
</div>
)
}