diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab3c44a..9eeebb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,5 @@ # * Runs unit tests on push/PR to main and staging. +# * Uses self-hosted runner for push events, GitHub-hosted for PRs (public repo security). name: Test on: @@ -7,6 +8,10 @@ on: pull_request: branches: [main, staging] +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read packages: read @@ -14,7 +19,7 @@ permissions: jobs: test: name: unit-tests - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }} steps: - uses: actions/checkout@v4 diff --git a/app/layout-content.tsx b/app/layout-content.tsx index 4e644a0..df84659 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -15,8 +15,9 @@ import { getUserOrganizations, switchContext, type OrganizationMember } from '@/ import { setSessionAction } from '@/app/actions/auth' import { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' -import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context' -import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper' +import { SettingsModalProvider } from '@/lib/settings-modal-context' +import { UnifiedSettingsProvider, useUnifiedSettings } from '@/lib/unified-settings-context' +import UnifiedSettingsModal from '@/components/settings/unified/UnifiedSettingsModal' const ORG_SWITCH_KEY = 'pulse_switching_org' @@ -52,7 +53,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) { const router = useRouter() const pathname = usePathname() const isOnline = useOnlineStatus() - const { openSettings } = useSettingsModal() + const { openUnifiedSettings } = useUnifiedSettings() const [orgs, setOrgs] = useState([]) const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => { if (typeof window === 'undefined') return false @@ -108,7 +109,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) { <> {showOfflineBar && } {children} - + ) } @@ -135,12 +136,12 @@ function LayoutInner({ children }: { children: React.ReactNode }) { rightSideActions={} apps={CIPHERA_APPS} currentAppId="pulse" - onOpenSettings={openSettings} + onOpenSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })} />
{children}
- + ) } @@ -157,7 +158,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) { appName="Pulse" isAuthenticated={false} /> - ) } @@ -165,7 +165,9 @@ function LayoutInner({ children }: { children: React.ReactNode }) { export default function LayoutContent({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx index 050b635..84aeffd 100644 --- a/app/sites/[id]/behavior/page.tsx +++ b/app/sites/[id]/behavior/page.tsx @@ -56,11 +56,11 @@ export default function BehaviorPage() { if (showSkeleton) return return ( -
+
{/* Header */}
-

+

Behavior

diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index b8872d0..594c431 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -135,7 +135,7 @@ export default function CDNPage() { if (showSkeleton) { return ( -

+
@@ -172,7 +172,7 @@ export default function CDNPage() { if (bunnyStatus && !bunnyStatus.connected) { return ( -
+
@@ -208,11 +208,11 @@ export default function CDNPage() { const totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0) return ( -
+
{/* Header */}
-

+

CDN Analytics

diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 0ce4fe1..289eabe 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -84,7 +84,7 @@ export default function FunnelReportPage() { if (loadError === 'not_found' || (!funnel && !stats && !loadError)) { return ( -

+

Funnel not found

) @@ -92,7 +92,7 @@ export default function FunnelReportPage() { if (loadError === 'forbidden') { return ( -
+

Access denied

+ {pageTitle} +
+ + {/* Realtime indicator */} + {lastUpdatedRef.current != null && ( +
+ + + + + Live · {formatUpdatedAgo(lastUpdatedRef.current)} +
+ )} +
+ ) +} + export default function DashboardShell({ siteId, children, @@ -31,20 +99,26 @@ export default function DashboardShell({ const openMobile = useCallback(() => setMobileOpen(true), []) return ( -
- - {/* Content panel — rounded corners, inset from edges. The left border doubles as the sidebar's right edge. */} -
- -
- {children} -
+ +
+ +
+ {/* Glass top bar — above content only, collapse icon reaches back into sidebar column */} + + {/* Content panel */} +
+ +
+ {children} +
+
+
-
+ ) } diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 4f3b488..0f5b7d9 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -1,11 +1,15 @@ 'use client' import { useState, useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { listSites, type Site } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' -import { useSettingsModal } from '@/lib/settings-modal-context' +import { useUnifiedSettings } from '@/lib/unified-settings-context' +import { useSidebar } from '@/lib/sidebar-context' +// `,` shortcut handled globally by UnifiedSettingsModal import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' import { logger } from '@/lib/utils/logger' @@ -20,8 +24,6 @@ import { CloudUploadIcon, HeartbeatIcon, SettingsIcon, - CollapseLeftIcon, - CollapseRightIcon, ChevronUpDownIcon, PlusIcon, XIcon, @@ -58,7 +60,6 @@ const CIPHERA_APPS: CipheraApp[] = [ }, ] -const SIDEBAR_KEY = 'pulse_sidebar_collapsed' const EXPANDED = 256 const COLLAPSED = 64 @@ -122,18 +123,46 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps const [faviconFailed, setFaviconFailed] = useState(false) const [faviconLoaded, setFaviconLoaded] = useState(false) const ref = useRef(null) + const panelRef = useRef(null) + const buttonRef = useRef(null) + const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null) const pathname = usePathname() const router = useRouter() const currentSite = sites.find((s) => s.id === siteId) const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null + const updatePosition = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + if (collapsed) { + // Collapsed: open to the right, like AppLauncher/UserMenu/Notifications + let top = rect.top + if (panelRef.current) { + const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8 + top = Math.min(top, Math.max(8, maxTop)) + } + setFixedPos({ left: rect.right + 8, top }) + } else { + // Expanded: open below the button + let top = rect.bottom + 4 + if (panelRef.current) { + const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8 + top = Math.min(top, Math.max(8, maxTop)) + } + setFixedPos({ left: rect.left, top }) + } + } + }, [collapsed]) + useEffect(() => { const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { + const target = e.target as Node + if ( + ref.current && !ref.current.contains(target) && + (!panelRef.current || !panelRef.current.contains(target)) + ) { if (open) { setOpen(false); setSearch('') - // Re-collapse if we auto-expanded - if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } } } } @@ -141,30 +170,92 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps return () => document.removeEventListener('mousedown', handler) }, [open, onCollapse, wasCollapsed]) + useEffect(() => { + if (open) { + updatePosition() + requestAnimationFrame(() => updatePosition()) + } + }, [open, updatePosition]) + + const closePicker = () => { + setOpen(false); setSearch('') + } + const switchSite = (id: string) => { router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) - setOpen(false); setSearch('') - // Re-collapse if we auto-expanded - if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } + closePicker() } const filtered = sites.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase()) ) + const dropdown = ( + + {open && ( + +
+ setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') closePicker() + }} + className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400" + autoFocus + /> +
+
+ {filtered.map((site) => ( + + ))} + {filtered.length === 0 &&

No sites found

} +
+
+ closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg"> + + Add new site + +
+
+ )} +
+ ) + return (
- {open && ( -
-
- setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setOpen(false) - setSearch('') - if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } - } - }} - className="w-full px-3 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400" - autoFocus - /> -
-
- {filtered.map((site) => ( - - ))} - {filtered.length === 0 &&

No sites found

} -
-
- setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-800 rounded-lg"> - - Add new site - -
-
- )} + {typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
) } @@ -269,7 +310,7 @@ function NavLink({ className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${ active ? 'bg-brand-orange/10 text-brand-orange' - : 'text-neutral-400 hover:text-white hover:bg-neutral-800 hover:translate-x-0.5' + : 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5' }`} > @@ -320,7 +361,7 @@ function SidebarContent({ return (
{/* App Switcher — top of sidebar (scope-level switch) */} -
+
@@ -347,7 +388,7 @@ function SidebarContent({ {NAV_GROUPS.map((group) => (
{c ? ( -
+
) : (

@@ -368,7 +409,7 @@ function SidebarContent({ {/* Bottom — utility items */} -

+
{/* Notifications, Profile — same layout as nav items */}
@@ -403,28 +444,6 @@ function SidebarContent({ )}
- - {/* Settings + Collapse */} -
- {!isMobile && ( -
- - {c && ( - - Expand (press [) - - )} -
- )} -
) @@ -442,17 +461,14 @@ export default function Sidebar({ const canEdit = user?.role === 'owner' || user?.role === 'admin' const pathname = usePathname() const router = useRouter() - const { openSettings } = useSettingsModal() + const { openUnifiedSettings } = useUnifiedSettings() const [sites, setSites] = useState([]) const [orgs, setOrgs] = useState([]) const [pendingHref, setPendingHref] = useState(null) const [mobileClosing, setMobileClosing] = useState(false) const wasCollapsedRef = useRef(false) const pickerOpenCallbackRef = useRef<(() => void) | null>(null) - // Safe to read localStorage directly — this component is loaded with ssr:false - const [collapsed, setCollapsed] = useState(() => { - return localStorage.getItem(SIDEBAR_KEY) !== 'false' - }) + const { collapsed, toggle, expand, collapse } = useSidebar() useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) useEffect(() => { @@ -476,30 +492,6 @@ export default function Sidebar({ } useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { - const tag = (e.target as HTMLElement)?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return - e.preventDefault(); toggle() - } - } - document.addEventListener('keydown', handler) - return () => document.removeEventListener('keydown', handler) - }, [collapsed]) - - const toggle = useCallback(() => { - setCollapsed((prev) => { const next = !prev; localStorage.setItem(SIDEBAR_KEY, String(next)); return next }) - }, []) - - const expand = useCallback(() => { - setCollapsed(false); localStorage.setItem(SIDEBAR_KEY, 'false') - }, []) - - const collapse = useCallback(() => { - setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true') - }, []) - const handleMobileClose = useCallback(() => { setMobileClosing(true) setTimeout(() => { @@ -514,7 +506,7 @@ export default function Sidebar({ <> {/* Desktop — ssr:false means this only renders on client, no hydration flash */} @@ -554,13 +546,13 @@ export default function Sidebar({ onClick={handleMobileClose} /> diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index d5fe19d..749979c 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -6,6 +6,7 @@ import { useEffect, useState, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' import Link from 'next/link' import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications' import { getAuthErrorMessage } from '@ciphera-net/ui' @@ -173,7 +174,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau aria-controls={open ? 'notification-dropdown' : undefined} className={isSidebar ? 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden transition-colors' - : 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors' + : 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-white/[0.06] transition-colors' } aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'} > @@ -198,20 +199,26 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau {(() => { - const panel = open ? ( -
+ {open && ( + -
+

Notifications

{unreadCount > 0 && ( + ) : ( + + )} +
+
+ ))} + + {children} +
+ ) +} diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx new file mode 100644 index 0000000..d1e39ea --- /dev/null +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -0,0 +1,491 @@ +'use client' + +import { useState, useCallback, useEffect, useRef } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { X, GearSix, Buildings, User } from '@phosphor-icons/react' +import { Button } from '@ciphera-net/ui' +import { useUnifiedSettings } from '@/lib/unified-settings-context' +import { useAuth } from '@/lib/auth/context' +import { useSite } from '@/lib/swr/dashboard' +import { listSites, type Site } from '@/lib/api/sites' + +// 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 ────────────────────────────────────────────────────── + +type SettingsContext = 'site' | 'workspace' | 'account' + +interface TabDef { + id: string + label: string +} + +const SITE_TABS: TabDef[] = [ + { id: 'general', label: 'General' }, + { id: 'goals', label: 'Goals' }, + { id: 'visibility', label: 'Visibility' }, + { id: 'privacy', label: 'Privacy' }, + { id: 'bot-spam', label: 'Bot & Spam' }, + { id: 'reports', label: 'Reports' }, + { id: 'integrations', label: 'Integrations' }, +] + +const WORKSPACE_TABS: TabDef[] = [ + { id: 'general', label: 'General' }, + { id: 'members', label: 'Members' }, + { id: 'billing', label: 'Billing' }, + { id: 'notifications', label: 'Notifications' }, + { id: 'audit', label: 'Audit Log' }, +] + +const ACCOUNT_TABS: TabDef[] = [ + { id: 'profile', label: 'Profile' }, + { id: 'security', label: 'Security' }, + { id: 'devices', label: 'Devices' }, +] + +// ─── Context Switcher ─────────────────────────────────────────── + +function ContextSwitcher({ + active, + onChange, + activeSiteDomain, +}: { + active: SettingsContext + onChange: (ctx: SettingsContext) => void + activeSiteDomain: string | null +}) { + return ( +
+ {/* Site button — locked to current site, no dropdown */} + {activeSiteDomain && ( + + )} + + + + +
+ ) +} + +// ─── Tab Bar ──────────────────────────────────────────────────── + +function TabBar({ + tabs, + activeTab, + onChange, +}: { + tabs: TabDef[] + activeTab: string + onChange: (id: string) => void +}) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ) +} + +// ─── Tab Content ──────────────────────────────────────────────── + +function ComingSoon({ label }: { label: string }) { + return ( +
+
+ +
+

{label}

+

+ This section is being migrated. For now, use the existing settings page. +

+
+ ) +} + +function TabContent({ + context, + activeTab, + siteId, + onDirtyChange, + onRegisterSave, +}: { + context: SettingsContext + activeTab: string + siteId: string | null + onDirtyChange: (dirty: boolean) => void + onRegisterSave: (fn: () => Promise) => void +}) { + const dirtyProps = { onDirtyChange, onRegisterSave } + // Site tabs + if (context === 'site' && siteId) { + switch (activeTab) { + case 'general': return + case 'goals': return + case 'visibility': return + case 'privacy': return + case 'bot-spam': return + case 'reports': return + case 'integrations': return + } + } + + // Workspace tabs + if (context === 'workspace') { + switch (activeTab) { + case 'general': return + case 'billing': return + case 'members': return + case 'notifications': return + case 'audit': return + } + } + + // Account tabs + if (context === 'account') { + switch (activeTab) { + case 'profile': return + case 'security': return + case 'devices': return + } + } + + return null +} + +// ─── Main Modal ───────────────────────────────────────────────── + +export default function UnifiedSettingsModal() { + const { isOpen, openUnifiedSettings, closeUnifiedSettings: closeSettings, initialTab: initTab } = useUnifiedSettings() + const { user } = useAuth() + + const [context, setContext] = useState('site') + const [siteTabs, setSiteTabs] = useState('general') + const [workspaceTabs, setWorkspaceTabs] = useState('general') + const [accountTabs, setAccountTabs] = useState('profile') + + const [sites, setSites] = useState([]) + const [activeSiteId, setActiveSiteId] = useState(null) + + // ─── Dirty state & pending navigation ──────────────────────── + const isDirtyRef = useRef(false) + const [isDirtyVisible, setIsDirtyVisible] = useState(false) + const pendingActionRef = useRef<(() => void) | null>(null) + const [hasPendingAction, setHasPendingAction] = useState(false) + const saveHandlerRef = useRef<(() => Promise) | null>(null) + const [saving, setSaving] = useState(false) + const [showGlass, setShowGlass] = useState(false) + + const handleDirtyChange = useCallback((dirty: boolean) => { + isDirtyRef.current = dirty + setIsDirtyVisible(dirty) + // If user saved and there was a pending action, execute it + if (!dirty && pendingActionRef.current) { + const action = pendingActionRef.current + pendingActionRef.current = null + setHasPendingAction(false) + action() + } + }, []) + + const handleRegisterSave = useCallback((fn: () => Promise) => { + saveHandlerRef.current = fn + }, []) + + const handleSaveFromBar = useCallback(async () => { + if (!saveHandlerRef.current) return + setSaving(true) + try { + await saveHandlerRef.current() + } finally { + setSaving(false) + } + }, []) + + /** Run action if clean, or store as pending if dirty */ + const guardedAction = useCallback((action: () => void) => { + if (isDirtyRef.current) { + pendingActionRef.current = action + setHasPendingAction(true) + } else { + action() + } + }, []) + + const handleDiscard = useCallback(() => { + isDirtyRef.current = false + setIsDirtyVisible(false) + setHasPendingAction(false) + saveHandlerRef.current = null + const action = pendingActionRef.current + pendingActionRef.current = null + action?.() + }, []) + + // Apply initial tab when modal opens + useEffect(() => { + if (isOpen && initTab) { + if (initTab.context) setContext(initTab.context) + if (initTab.tab) { + if (initTab.context === 'site') setSiteTabs(initTab.tab) + else if (initTab.context === 'workspace') setWorkspaceTabs(initTab.tab) + else if (initTab.context === 'account') setAccountTabs(initTab.tab) + } + } + }, [isOpen, initTab]) + + // Reset dirty state when modal opens + useEffect(() => { + if (isOpen) { + isDirtyRef.current = false + pendingActionRef.current = null + setHasPendingAction(false) + setShowGlass(true) + } + }, [isOpen]) + + // Detect site from URL and load sites list when modal opens + useEffect(() => { + if (!isOpen || !user?.org_id) return + + if (typeof window !== 'undefined') { + const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/) + if (match) { + setActiveSiteId(match[1]) + setContext('site') + } else { + setActiveSiteId(null) + if (!initTab?.context) setContext('workspace') + } + } + + listSites().then(data => { + setSites(Array.isArray(data) ? data : []) + }).catch(() => {}) + }, [isOpen, user?.org_id]) + + // Global keyboard shortcuts: `,` toggles settings, Escape closes + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + + if (e.key === ',' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + if (isOpen) guardedAction(closeSettings) + else openUnifiedSettings() + } + if (e.key === 'Escape' && isOpen) { + guardedAction(closeSettings) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [isOpen, openUnifiedSettings, closeSettings, guardedAction]) + + const tabs = context === 'site' ? SITE_TABS : context === 'workspace' ? WORKSPACE_TABS : ACCOUNT_TABS + const activeTab = context === 'site' ? siteTabs : context === 'workspace' ? workspaceTabs : accountTabs + const setActiveTab = context === 'site' ? setSiteTabs : context === 'workspace' ? setWorkspaceTabs : setAccountTabs + + const handleContextChange = useCallback((ctx: SettingsContext) => { + guardedAction(() => { + setContext(ctx) + if (ctx === 'site') setSiteTabs('general') + else if (ctx === 'workspace') setWorkspaceTabs('general') + else if (ctx === 'account') setAccountTabs('profile') + }) + }, [guardedAction]) + + const handleTabChange = useCallback((tabId: string) => { + guardedAction(() => setActiveTab(tabId)) + }, [guardedAction, setActiveTab]) + + const handleClose = useCallback(() => { + guardedAction(closeSettings) + }, [guardedAction, closeSettings]) + + const handleBackdropClick = useCallback(() => { + guardedAction(closeSettings) + }, [guardedAction, closeSettings]) + + return ( + <> + {/* Backdrop — fades in/out */} +
+ + {/* Glass panel — always mounted, fades out on close */} +
+
e.stopPropagation()} + > + {/* Content animates in/out */} + setShowGlass(false)}> + {isOpen && ( + + {/* Header */} +
+
+

Settings

+ +
+ + {/* Context Switcher */} + s.id === activeSiteId)?.domain ?? null} + /> + + {/* Tabs */} +
+ +
+
+ + {/* Content */} +
+ + + + + +
+ + {/* Save bar */} + + {isDirtyVisible && ( + +
+ + {hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'} + +
+ {hasPendingAction && ( + + )} + +
+
+
+ )} +
+
+ )} +
+
+
+ + ) +} 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/AccountProfileTab.tsx b/components/settings/unified/tabs/AccountProfileTab.tsx new file mode 100644 index 0000000..3e1f777 --- /dev/null +++ b/components/settings/unified/tabs/AccountProfileTab.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { Input, toast, Spinner } from '@ciphera-net/ui' +import { useAuth } from '@/lib/auth/context' +import { updateDisplayName } from '@/lib/api/user' +import { deleteAccount } from '@/lib/api/user' +import { getAuthErrorMessage } from '@ciphera-net/ui' +import { DangerZone } from '@/components/settings/unified/DangerZone' + +export default function AccountProfileTab({ onDirtyChange, onRegisterSave }: { onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise) => void }) { + const { user, refresh, logout } = useAuth() + const [displayName, setDisplayName] = useState('') + const initialRef = useRef('') + const hasInitialized = useRef(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [deleteText, setDeleteText] = useState('') + const [deletePassword, setDeletePassword] = useState('') + const [deleting, setDeleting] = useState(false) + + useEffect(() => { + if (!user || hasInitialized.current) return + setDisplayName(user.display_name || '') + initialRef.current = user.display_name || '' + hasInitialized.current = true + }, [user]) + + // Track dirty state + useEffect(() => { + if (!hasInitialized.current) return + onDirtyChange?.(displayName !== initialRef.current) + }, [displayName, onDirtyChange]) + + const handleSave = useCallback(async () => { + try { + await updateDisplayName(displayName.trim()) + await refresh() + initialRef.current = displayName.trim() + onDirtyChange?.(false) + toast.success('Profile updated') + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to update profile') + } + }, [displayName, refresh, onDirtyChange]) + + useEffect(() => { + onRegisterSave?.(handleSave) + }, [handleSave, onRegisterSave]) + + const handleDelete = async () => { + if (deleteText !== 'DELETE' || !deletePassword) return + setDeleting(true) + try { + await deleteAccount(deletePassword) + logout() + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete account') + setDeleting(false) + } + } + + if (!user) return
+ + return ( +
+
+

Profile

+

Manage your personal account settings.

+
+ + {/* Display Name */} +
+
+ + setDisplayName(e.target.value)} + placeholder="Your name" + /> +
+ +
+ + +

Email changes require password verification. Use Ciphera Auth to change your email.

+
+
+ + {/* Danger Zone */} + setShowDeleteConfirm(prev => !prev), + }, + ]} + /> + + {showDeleteConfirm && ( +
+

This will permanently delete:

+
    +
  • Your account and all personal data
  • +
  • All sessions and trusted devices
  • +
  • You will be removed from all organizations
  • +
+
+ + setDeletePassword(e.target.value)} + placeholder="Enter your password" + /> +
+
+ + setDeleteText(e.target.value)} + placeholder="DELETE" + /> +
+
+ + +
+
+ )} +
+ ) +} 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..9f5574f --- /dev/null +++ b/components/settings/unified/tabs/SiteBotSpamTab.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui' +import { ShieldCheck } from '@phosphor-icons/react' +import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard' +import { updateSite } from '@/lib/api/sites' +import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter' + +export default function SiteBotSpamTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise) => void }) { + const { data: site, mutate } = useSite(siteId) + const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId) + const [filterBots, setFilterBots] = useState(false) + const initialFilterRef = useRef(null) + + const [botView, setBotView] = useState<'review' | 'blocked'>('review') + const [suspiciousOnly, setSuspiciousOnly] = useState(true) + const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [botDateRange] = useState(() => getDateRange(7)) + + const { data: sessionsData, mutate: mutateSessions } = useSessions(siteId, botDateRange.start, botDateRange.end, botView === 'review' ? suspiciousOnly : false) + const sessions = sessionsData?.sessions + + const hasInitialized = useRef(false) + useEffect(() => { + if (!site || hasInitialized.current) return + setFilterBots(site.filter_bots ?? false) + initialFilterRef.current = site.filter_bots ?? false + hasInitialized.current = true + }, [site]) + + // Track dirty state + useEffect(() => { + if (initialFilterRef.current === null) return + const dirty = filterBots !== initialFilterRef.current + onDirtyChange?.(dirty) + }, [filterBots, onDirtyChange]) + + const handleSave = useCallback(async () => { + try { + await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots }) + await mutate() + initialFilterRef.current = filterBots + onDirtyChange?.(false) + toast.success('Bot filtering updated') + } catch { + toast.error('Failed to save') + } + }, [siteId, site?.name, filterBots, mutate, onDirtyChange]) + + useEffect(() => { + onRegisterSave?.(handleSave) + }, [handleSave, onRegisterSave]) + + const handleBotFilter = async (sessionIds: string[]) => { + try { + await botFilterSessions(siteId, sessionIds) + toast.success(`${sessionIds.length} session(s) flagged as bot`) + setSelectedSessions(new Set()) + mutateSessions() + mutateBotStats() + } catch { + toast.error('Failed to flag sessions') + } + } + + const handleBotUnfilter = async (sessionIds: string[]) => { + try { + await botUnfilterSessions(siteId, sessionIds) + toast.success(`${sessionIds.length} session(s) unblocked`) + setSelectedSessions(new Set()) + mutateSessions() + mutateBotStats() + } catch { + toast.error('Failed to unblock sessions') + } + } + + 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

+
+
+ )} + + {/* Session Review */} +
+
+

Session Review

+ {/* Review/Blocked toggle */} +
+ + +
+
+ + {/* Suspicious only filter (review mode only) */} + {botView === 'review' && ( +
+
+

Suspicious only

+

Show only sessions flagged as suspicious.

+
+ setSuspiciousOnly(v => !v)} /> +
+ )} + + {/* Bulk actions bar */} + {selectedSessions.size > 0 && ( +
+ {selectedSessions.size} selected + {botView === 'review' ? ( + + ) : ( + + )} + +
+ )} + + {/* Session cards */} +
+ {(sessions || []) + .filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered) + .map(session => ( +
+ { + const next = new Set(selectedSessions) + e.target.checked ? next.add(session.session_id) : next.delete(session.session_id) + setSelectedSessions(next) + }} + className="w-4 h-4 shrink-0 cursor-pointer" + style={{ accentColor: '#FD5E0F' }} + /> +
+
+ {session.first_page || '/'} + {session.suspicion_score != null && ( + = 5 ? 'bg-red-900/30 text-red-400' : + session.suspicion_score >= 3 ? 'bg-yellow-900/30 text-yellow-400' : + 'bg-neutral-800 text-neutral-400' + }`}> + {session.suspicion_score >= 5 ? 'High risk' : session.suspicion_score >= 3 ? 'Suspicious' : 'Low risk'} + + )} +
+
+ {session.pageviews} page(s) + {session.duration ? `${Math.round(session.duration)}s` : 'No duration'} + {[session.city, session.country].filter(Boolean).join(', ') || 'Unknown location'} + {session.browser || 'Unknown browser'} + {session.referrer || 'Direct'} +
+
+ +
+ ))} + {(!sessions || sessions.filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered).length === 0) && ( +

+ {botView === 'blocked' ? 'No blocked sessions' : 'No suspicious sessions found'} +

+ )} +
+
+ +
+ ) +} diff --git a/components/settings/unified/tabs/SiteGeneralTab.tsx b/components/settings/unified/tabs/SiteGeneralTab.tsx new file mode 100644 index 0000000..0a0ddbb --- /dev/null +++ b/components/settings/unified/tabs/SiteGeneralTab.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui' +import { useSite } from '@/lib/swr/dashboard' +import { updateSite, resetSiteData } from '@/lib/api/sites' +import { useAuth } from '@/lib/auth/context' +import { useUnifiedSettings } from '@/lib/unified-settings-context' +import { DangerZone } from '@/components/settings/unified/DangerZone' +import DeleteSiteModal from '@/components/sites/DeleteSiteModal' +import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' +import VerificationModal from '@/components/sites/VerificationModal' + +const TIMEZONES = [ + { value: 'UTC', label: 'UTC' }, + { value: 'Europe/London', label: 'Europe/London (GMT)' }, + { value: 'Europe/Brussels', label: 'Europe/Brussels (CET)' }, + { value: 'Europe/Berlin', label: 'Europe/Berlin (CET)' }, + { value: 'Europe/Paris', label: 'Europe/Paris (CET)' }, + { value: 'Europe/Amsterdam', label: 'Europe/Amsterdam (CET)' }, + { value: 'America/New_York', label: 'America/New York (EST)' }, + { value: 'America/Chicago', label: 'America/Chicago (CST)' }, + { value: 'America/Denver', label: 'America/Denver (MST)' }, + { value: 'America/Los_Angeles', label: 'America/Los Angeles (PST)' }, + { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' }, + { value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' }, + { value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' }, + { value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' }, +] + +export default function SiteGeneralTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise) => void }) { + const router = useRouter() + const { user } = useAuth() + const { closeUnifiedSettings: closeSettings } = useUnifiedSettings() + const { data: site, mutate } = useSite(siteId) + const [name, setName] = useState('') + const [timezone, setTimezone] = useState('UTC') + const [scriptFeatures, setScriptFeatures] = useState>({}) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [showVerificationModal, setShowVerificationModal] = useState(false) + + const canEdit = user?.role === 'owner' || user?.role === 'admin' + const initialRef = useRef('') + const hasInitialized = useRef(false) + + useEffect(() => { + if (!site || hasInitialized.current) return + setName(site.name || '') + setTimezone(site.timezone || 'UTC') + setScriptFeatures(site.script_features || {}) + initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC', scriptFeatures: JSON.stringify(site.script_features || {}) }) + hasInitialized.current = true + }, [site]) + + // Track dirty state + useEffect(() => { + if (!initialRef.current) return + const current = JSON.stringify({ name, timezone, scriptFeatures: JSON.stringify(scriptFeatures) }) + onDirtyChange?.(current !== initialRef.current) + }, [name, timezone, scriptFeatures, onDirtyChange]) + + const handleSave = useCallback(async () => { + if (!site) return + try { + await updateSite(siteId, { name, timezone, script_features: scriptFeatures }) + await mutate() + initialRef.current = JSON.stringify({ name, timezone, scriptFeatures: JSON.stringify(scriptFeatures) }) + onDirtyChange?.(false) + toast.success('Site updated') + } catch { + toast.error('Failed to save') + } + }, [site, siteId, name, timezone, scriptFeatures, mutate, onDirtyChange]) + + useEffect(() => { + onRegisterSave?.(handleSave) + }, [handleSave, onRegisterSave]) + + const handleResetData = async () => { + if (!confirm('Are you sure you want to delete ALL data for this site? This action cannot be undone.')) return + try { + await resetSiteData(siteId) + toast.success('All site data has been reset') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to reset site data') + } + } + + if (!site || !hasInitialized.current) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Site details */} +
+
+

General Configuration

+

Update your site details and tracking script.

+
+ +
+
+
+ + setName(e.target.value)} placeholder="My Website" /> +
+
+ + +

Cannot be changed.

+
+
+ +
+ + setName(e.target.value)} + placeholder="e.g. Sign Up" + /> +
+
+ + setEventName(e.target.value)} + placeholder="e.g. signup_click" + disabled={!!editing} + /> +
+
+
+ + +
+
+ )} + + {/* Goals list */} + {goals.length === 0 && !creating ? ( +
+

No goals yet. Add a goal to track custom events.

+ +
+ ) : ( +
+ {goals.map(goal => ( +
+
+

{goal.name}

+

{goal.event_name}

+
+
+ + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/components/settings/unified/tabs/SiteIntegrationsTab.tsx b/components/settings/unified/tabs/SiteIntegrationsTab.tsx new file mode 100644 index 0000000..4f56c93 --- /dev/null +++ b/components/settings/unified/tabs/SiteIntegrationsTab.tsx @@ -0,0 +1,383 @@ +'use client' + +import { useState } from 'react' +import { Button, Input, Select, toast, Spinner } from '@ciphera-net/ui' +import { Plugs, LinkBreak, ShieldCheck } from '@phosphor-icons/react' +import { useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard' +import { disconnectGSC, getGSCAuthURL } from '@/lib/api/gsc' +import { disconnectBunny, getBunnyPullZones, connectBunny, type BunnyPullZone } from '@/lib/api/bunny' +import { getAuthErrorMessage } from '@ciphera-net/ui' +import { formatDateTime } from '@/lib/utils/formatDate' + +function GoogleIcon() { + return ( + + + + + + + ) +} + +function BunnyIcon() { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} + +function IntegrationCard({ + icon, + name, + description, + connected, + detail, + onConnect, + onDisconnect, + connectLabel = 'Connect', + children, +}: { + icon: React.ReactNode + name: string + description: string + connected: boolean + detail?: string + onConnect: () => void + onDisconnect: () => void + connectLabel?: string + children?: React.ReactNode +}) { + return ( +
+
+
+
{icon}
+
+
+

{name}

+ {connected && ( + + + Connected + + )} +
+

{detail || description}

+
+
+ {connected ? ( + + ) : ( + + )} +
+ {children} +
+ ) +} + +function SecurityNote({ text }: { text: string }) { + return ( +
+ +

{text}

+
+ ) +} + +function StatusDot({ status }: { status?: string }) { + const color = + status === 'active' ? 'bg-green-400' : + status === 'syncing' ? 'bg-yellow-400 animate-pulse' : + status === 'error' ? 'bg-red-400' : + 'bg-neutral-500' + + const label = + status === 'active' ? 'Connected' : + status === 'syncing' ? 'Syncing' : + status === 'error' ? 'Error' : + 'Unknown' + + return ( + + + {label} + + ) +} + +function GSCDetails({ gscStatus }: { gscStatus: { connected: boolean; google_email?: string; gsc_property?: string; status?: string; last_synced_at?: string | null; error_message?: string | null } }) { + if (!gscStatus.connected) return null + + const rows = [ + { label: 'Google Account', value: gscStatus.google_email || 'Unknown' }, + { label: 'GSC Property', value: gscStatus.gsc_property || 'Unknown' }, + { label: 'Last Synced', value: gscStatus.last_synced_at ? formatDateTime(new Date(gscStatus.last_synced_at)) : 'Never' }, + ] + + return ( +
+
+ {rows.map(row => ( +
+ {row.label} + {row.value} +
+ ))} +
+ {gscStatus.error_message && ( +
+

{gscStatus.error_message}

+
+ )} +
+ ) +} + +function BunnySetupForm({ siteId, onConnected }: { siteId: string; onConnected: () => void }) { + const [apiKey, setApiKey] = useState('') + const [pullZones, setPullZones] = useState([]) + const [selectedZone, setSelectedZone] = useState(null) + const [loadingZones, setLoadingZones] = useState(false) + const [connecting, setConnecting] = useState(false) + const [zonesLoaded, setZonesLoaded] = useState(false) + + const handleLoadZones = async () => { + if (!apiKey.trim()) { + toast.error('Please enter your BunnyCDN API key') + return + } + setLoadingZones(true) + try { + const data = await getBunnyPullZones(siteId, apiKey.trim()) + setPullZones(data.pull_zones || []) + setSelectedZone(null) + setZonesLoaded(true) + if (!data.pull_zones?.length) { + toast.error('No pull zones found for this API key') + } + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to load pull zones') + } finally { + setLoadingZones(false) + } + } + + const handleConnect = async () => { + if (!selectedZone) { + toast.error('Please select a pull zone') + return + } + setConnecting(true) + try { + await connectBunny(siteId, apiKey.trim(), selectedZone.id, selectedZone.name) + toast.success('BunnyCDN connected successfully') + onConnected() + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to connect BunnyCDN') + } finally { + setConnecting(false) + } + } + + return ( +
+
+
+ +
+ setApiKey(e.target.value)} + placeholder="Enter your BunnyCDN API key" + className="flex-1" + /> + +
+
+ + {zonesLoaded && pullZones.length > 0 && ( +
+ + +
+
+ + {/* Data Retention */} +
+

Data Retention

+ + {subscriptionError && ( +
+

Plan limits could not be loaded.

+ +
+ )} + +
+
+
+

Keep raw event data for

+

Events older than this are automatically deleted. Aggregated daily stats are kept permanently.

+
+