feat: replace settings page with SettingsModal
- Add SettingsModalProvider context and SettingsModalWrapper - Wire Header onOpenSettings callback via LayoutInner pattern - Remove old /settings page and SettingsPageClient - Bump @ciphera-net/ui to ^0.0.88
This commit is contained in:
@@ -13,6 +13,8 @@ import { getUserOrganizations, switchContext, type OrganizationMember } from '@/
|
|||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
|
||||||
|
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
|
||||||
|
|
||||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||||
|
|
||||||
@@ -44,10 +46,11 @@ const CIPHERA_APPS: CipheraApp[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isOnline = useOnlineStatus()
|
const isOnline = useOnlineStatus()
|
||||||
|
const { openSettings } = useSettingsModal()
|
||||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||||
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
@@ -117,6 +120,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||||
apps={CIPHERA_APPS}
|
apps={CIPHERA_APPS}
|
||||||
currentAppId="pulse"
|
currentAppId="pulse"
|
||||||
|
onOpenSettings={openSettings}
|
||||||
customNavItems={
|
customNavItems={
|
||||||
<>
|
<>
|
||||||
{!auth.user && (
|
{!auth.user && (
|
||||||
@@ -141,6 +145,15 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
appName="Pulse"
|
appName="Pulse"
|
||||||
isAuthenticated={!!auth.user}
|
isAuthenticated={!!auth.user}
|
||||||
/>
|
/>
|
||||||
|
<SettingsModalWrapper />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SettingsModalProvider>
|
||||||
|
<LayoutInner>{children}</LayoutInner>
|
||||||
|
</SettingsModalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,532 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useAuth } from '@/lib/auth/context'
|
|
||||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
|
||||||
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
|
|
||||||
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
|
|
||||||
import { updateUserPreferences } from '@/lib/api/user'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import {
|
|
||||||
UserIcon,
|
|
||||||
LockIcon,
|
|
||||||
BoxIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
} from '@ciphera-net/ui'
|
|
||||||
|
|
||||||
// Inline SVG icons not available in ciphera-ui
|
|
||||||
function BellIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
|
||||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
|
|
||||||
type ProfileSubTab = 'profile' | 'security' | 'preferences'
|
|
||||||
type NotificationSubTab = 'security' | 'center'
|
|
||||||
|
|
||||||
type ActiveSelection =
|
|
||||||
| { section: 'profile'; subTab: ProfileSubTab }
|
|
||||||
| { section: 'notifications'; subTab: NotificationSubTab }
|
|
||||||
| { section: 'account' }
|
|
||||||
| { section: 'devices' }
|
|
||||||
| { section: 'activity' }
|
|
||||||
|
|
||||||
type ExpandableSection = 'profile' | 'notifications' | 'account'
|
|
||||||
|
|
||||||
// --- Sidebar Components ---
|
|
||||||
|
|
||||||
function SectionHeader({
|
|
||||||
expanded,
|
|
||||||
active,
|
|
||||||
onToggle,
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
hasChildren = true,
|
|
||||||
}: {
|
|
||||||
expanded: boolean
|
|
||||||
active: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
icon: React.ElementType
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
hasChildren?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className={`w-full flex items-start gap-3 px-4 py-3 text-left rounded-xl transition-all duration-200 ${
|
|
||||||
active
|
|
||||||
? 'bg-brand-orange/10 text-brand-orange'
|
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="font-medium">{label}</span>
|
|
||||||
{description && (
|
|
||||||
<p className={`text-xs mt-0.5 ${active ? 'text-brand-orange/70' : 'text-neutral-500'}`}>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasChildren ? (
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={`w-4 h-4 shrink-0 mt-1 transition-transform duration-200 ${
|
|
||||||
expanded ? '' : '-rotate-90'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronRightIcon className={`w-4 h-4 shrink-0 mt-1 transition-transform ${active ? 'rotate-90' : ''}`} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubItem({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
label,
|
|
||||||
external = false,
|
|
||||||
}: {
|
|
||||||
active: boolean
|
|
||||||
onClick: () => void
|
|
||||||
label: string
|
|
||||||
external?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`w-full flex items-center gap-2.5 pl-12 pr-4 py-2 text-sm text-left rounded-lg transition-all duration-150 ${
|
|
||||||
active
|
|
||||||
? 'text-brand-orange font-medium bg-brand-orange/5'
|
|
||||||
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex-1">{label}</span>
|
|
||||||
{external && <ExternalLinkIcon className="w-3 h-3 opacity-60" />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandableSubItems({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{expanded && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="py-1 space-y-0.5">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Content Components ---
|
|
||||||
|
|
||||||
// Security Alerts Card (granular security toggles)
|
|
||||||
const SECURITY_ALERT_OPTIONS = [
|
|
||||||
{ key: 'login_alerts', label: 'Login Activity', description: 'New device sign-ins and suspicious login attempts.' },
|
|
||||||
{ key: 'password_alerts', label: 'Password Changes', description: 'Password changes and session revocations.' },
|
|
||||||
{ key: 'two_factor_alerts', label: 'Two-Factor Authentication', description: '2FA enabled/disabled and recovery code changes.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function SecurityAlertsCard() {
|
|
||||||
const { user } = useAuth()
|
|
||||||
const [emailNotifications, setEmailNotifications] = useState<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.preferences?.email_notifications) {
|
|
||||||
setEmailNotifications(user.preferences.email_notifications)
|
|
||||||
} else {
|
|
||||||
const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({
|
|
||||||
...acc,
|
|
||||||
[option.key]: true
|
|
||||||
}), {} as Record<string, boolean>)
|
|
||||||
setEmailNotifications(defaults)
|
|
||||||
}
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
const handleToggle = async (key: string) => {
|
|
||||||
const newState = {
|
|
||||||
...emailNotifications,
|
|
||||||
[key]: !emailNotifications[key]
|
|
||||||
}
|
|
||||||
setEmailNotifications(newState)
|
|
||||||
try {
|
|
||||||
await updateUserPreferences({
|
|
||||||
email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean }
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
setEmailNotifications(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: !prev[key]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="p-2 rounded-lg bg-brand-orange/10">
|
|
||||||
<BellIcon className="w-5 h-5 text-brand-orange" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Security Alerts</h2>
|
|
||||||
<p className="text-sm text-neutral-500">Choose which security events trigger email alerts</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{SECURITY_ALERT_OPTIONS.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.key}
|
|
||||||
className={`flex items-center justify-between p-4 border rounded-xl transition-all duration-200 ${
|
|
||||||
emailNotifications[item.key]
|
|
||||||
? 'bg-orange-50 dark:bg-brand-orange/10 border-brand-orange shadow-sm'
|
|
||||||
: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className={`block text-sm font-medium transition-colors duration-200 ${
|
|
||||||
emailNotifications[item.key] ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
<span className={`block text-xs transition-colors duration-200 ${
|
|
||||||
emailNotifications[item.key] ? 'text-brand-orange/80' : 'text-neutral-500 dark:text-neutral-400'
|
|
||||||
}`}>
|
|
||||||
{item.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(item.key)}
|
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
|
||||||
emailNotifications[item.key] ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
||||||
emailNotifications[item.key] ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccountManagementCard() {
|
|
||||||
const accountLinks = [
|
|
||||||
{
|
|
||||||
label: 'Profile & Personal Info',
|
|
||||||
description: 'Update your name, email, and avatar',
|
|
||||||
href: 'https://auth.ciphera.net/settings',
|
|
||||||
icon: UserIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Security & 2FA',
|
|
||||||
description: 'Password, two-factor authentication, and passkeys',
|
|
||||||
href: 'https://auth.ciphera.net/settings?tab=security',
|
|
||||||
icon: LockIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Active Sessions',
|
|
||||||
description: 'Manage devices logged into your account',
|
|
||||||
href: 'https://auth.ciphera.net/settings?tab=sessions',
|
|
||||||
icon: BoxIcon,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="p-2 rounded-lg bg-brand-orange/10">
|
|
||||||
<UserIcon className="w-5 h-5 text-brand-orange" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Ciphera Account</h2>
|
|
||||||
<p className="text-sm text-neutral-500">Manage your account across all Ciphera products</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{accountLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.label}
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-start gap-3 p-3 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/30 hover:bg-brand-orange/5 transition-all group"
|
|
||||||
>
|
|
||||||
<link.icon className="w-5 h-5 text-neutral-400 group-hover:text-brand-orange shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-neutral-900 dark:text-white group-hover:text-brand-orange">
|
|
||||||
{link.label}
|
|
||||||
</span>
|
|
||||||
<ExternalLinkIcon className="w-3.5 h-3.5 text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-neutral-500 mt-0.5">{link.description}</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="w-4 h-4 text-neutral-400 shrink-0 mt-1" />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
|
||||||
<p className="text-xs text-neutral-500">
|
|
||||||
These settings apply to your Ciphera Account and affect all products (Drop, Pulse, and Auth).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main Settings Section ---
|
|
||||||
|
|
||||||
function AppSettingsSection() {
|
|
||||||
const [active, setActive] = useState<ActiveSelection>({ section: 'profile', subTab: 'profile' })
|
|
||||||
const [expanded, setExpanded] = useState<Set<ExpandableSection>>(new Set(['profile']))
|
|
||||||
|
|
||||||
const toggleSection = (section: ExpandableSection) => {
|
|
||||||
setExpanded(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(section)) {
|
|
||||||
next.delete(section)
|
|
||||||
} else {
|
|
||||||
next.add(section)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectSubTab = (selection: ActiveSelection) => {
|
|
||||||
setActive(selection)
|
|
||||||
if ('subTab' in selection) {
|
|
||||||
setExpanded(prev => new Set(prev).add(selection.section as ExpandableSection))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (active.section) {
|
|
||||||
case 'profile':
|
|
||||||
return <ProfileSettings activeTab={active.subTab} />
|
|
||||||
case 'notifications':
|
|
||||||
if (active.subTab === 'security') return <SecurityAlertsCard />
|
|
||||||
if (active.subTab === 'center') return (
|
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-8 shadow-sm">
|
|
||||||
<div className="text-center max-w-md mx-auto">
|
|
||||||
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
|
|
||||||
<p className="text-sm text-neutral-500 mb-4">
|
|
||||||
View and manage all your notifications in one place.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/notifications"
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors"
|
|
||||||
>
|
|
||||||
Open Notification Center
|
|
||||||
<ChevronRightIcon className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
case 'account':
|
|
||||||
return <AccountManagementCard />
|
|
||||||
case 'devices':
|
|
||||||
return <TrustedDevicesCard />
|
|
||||||
case 'activity':
|
|
||||||
return <SecurityActivityCard />
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<nav className="w-full lg:w-72 flex-shrink-0 space-y-6">
|
|
||||||
{/* Pulse Settings Section */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
|
|
||||||
Pulse Settings
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div>
|
|
||||||
<SectionHeader
|
|
||||||
expanded={expanded.has('profile')}
|
|
||||||
active={active.section === 'profile'}
|
|
||||||
onToggle={() => {
|
|
||||||
toggleSection('profile')
|
|
||||||
if (!expanded.has('profile')) {
|
|
||||||
selectSubTab({ section: 'profile', subTab: 'profile' })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={UserIcon}
|
|
||||||
label="Profile & Preferences"
|
|
||||||
description="Your profile and sharing defaults"
|
|
||||||
/>
|
|
||||||
<ExpandableSubItems expanded={expanded.has('profile')}>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'profile' && active.subTab === 'profile'}
|
|
||||||
onClick={() => selectSubTab({ section: 'profile', subTab: 'profile' })}
|
|
||||||
label="Profile"
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'profile' && active.subTab === 'security'}
|
|
||||||
onClick={() => selectSubTab({ section: 'profile', subTab: 'security' })}
|
|
||||||
label="Security"
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'profile' && active.subTab === 'preferences'}
|
|
||||||
onClick={() => selectSubTab({ section: 'profile', subTab: 'preferences' })}
|
|
||||||
label="Preferences"
|
|
||||||
/>
|
|
||||||
</ExpandableSubItems>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications (expandable) */}
|
|
||||||
<div>
|
|
||||||
<SectionHeader
|
|
||||||
expanded={expanded.has('notifications')}
|
|
||||||
active={active.section === 'notifications'}
|
|
||||||
onToggle={() => {
|
|
||||||
toggleSection('notifications')
|
|
||||||
if (!expanded.has('notifications')) {
|
|
||||||
selectSubTab({ section: 'notifications', subTab: 'security' })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={BellIcon}
|
|
||||||
label="Notifications"
|
|
||||||
description="Email and in-app notifications"
|
|
||||||
/>
|
|
||||||
<ExpandableSubItems expanded={expanded.has('notifications')}>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'notifications' && active.subTab === 'security'}
|
|
||||||
onClick={() => selectSubTab({ section: 'notifications', subTab: 'security' })}
|
|
||||||
label="Security Alerts"
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'notifications' && active.subTab === 'center'}
|
|
||||||
onClick={() => selectSubTab({ section: 'notifications', subTab: 'center' })}
|
|
||||||
label="Notification Center"
|
|
||||||
/>
|
|
||||||
</ExpandableSubItems>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ciphera Account Section */}
|
|
||||||
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
|
||||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
|
|
||||||
Ciphera Account
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<SectionHeader
|
|
||||||
expanded={expanded.has('account')}
|
|
||||||
active={active.section === 'account' || active.section === 'devices' || active.section === 'activity'}
|
|
||||||
onToggle={() => {
|
|
||||||
toggleSection('account')
|
|
||||||
if (!expanded.has('account')) {
|
|
||||||
setActive({ section: 'account' })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={LockIcon}
|
|
||||||
label="Manage Account"
|
|
||||||
description="Security, 2FA, and sessions"
|
|
||||||
/>
|
|
||||||
<ExpandableSubItems expanded={expanded.has('account')}>
|
|
||||||
<SubItem
|
|
||||||
active={false}
|
|
||||||
onClick={() => window.open('https://auth.ciphera.net/settings', '_blank')}
|
|
||||||
label="Profile & Personal Info"
|
|
||||||
external
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={false}
|
|
||||||
onClick={() => window.open('https://auth.ciphera.net/settings?tab=security', '_blank')}
|
|
||||||
label="Security & 2FA"
|
|
||||||
external
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={false}
|
|
||||||
onClick={() => window.open('https://auth.ciphera.net/settings?tab=sessions', '_blank')}
|
|
||||||
label="Active Sessions"
|
|
||||||
external
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'devices'}
|
|
||||||
onClick={() => setActive({ section: 'devices' })}
|
|
||||||
label="Trusted Devices"
|
|
||||||
/>
|
|
||||||
<SubItem
|
|
||||||
active={active.section === 'activity'}
|
|
||||||
onClick={() => setActive({ section: 'activity' })}
|
|
||||||
label="Security Activity"
|
|
||||||
/>
|
|
||||||
</ExpandableSubItems>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPageClient() {
|
|
||||||
const { user } = useAuth()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">Settings</h1>
|
|
||||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
|
||||||
Manage your Pulse preferences and Ciphera account settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Breadcrumb / Context */}
|
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
|
||||||
<span>You are signed in as</span>
|
|
||||||
<span className="font-medium text-neutral-900 dark:text-white">{user?.email}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<a
|
|
||||||
href="https://auth.ciphera.net/settings"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-brand-orange hover:underline inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Manage in Ciphera Account
|
|
||||||
<ExternalLinkIcon className="w-3 h-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Content */}
|
|
||||||
<AppSettingsSection />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import SettingsPageClient from './SettingsPageClient'
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Settings - Pulse',
|
|
||||||
description: 'Manage your account settings',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
|
||||||
<SettingsPageClient />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
186
components/settings/SettingsModalWrapper.tsx
Normal file
186
components/settings/SettingsModalWrapper.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { SettingsModal, type SettingsSection } from '@ciphera-net/ui'
|
||||||
|
import { UserIcon, LockIcon, ChevronRightIcon } from '@ciphera-net/ui'
|
||||||
|
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||||
|
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
|
||||||
|
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
|
||||||
|
import { useSettingsModal } from '@/lib/settings-modal-context'
|
||||||
|
import { useAuth } from '@/lib/auth/context'
|
||||||
|
import { updateUserPreferences } from '@/lib/api/user'
|
||||||
|
|
||||||
|
// Inline SVG icons not available in ciphera-ui
|
||||||
|
function BellIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security Alerts ---
|
||||||
|
|
||||||
|
const SECURITY_ALERT_OPTIONS = [
|
||||||
|
{ key: 'login_alerts', label: 'Login Activity', description: 'New device sign-ins and suspicious login attempts.' },
|
||||||
|
{ key: 'password_alerts', label: 'Password Changes', description: 'Password changes and session revocations.' },
|
||||||
|
{ key: 'two_factor_alerts', label: 'Two-Factor Authentication', description: '2FA enabled/disabled and recovery code changes.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SecurityAlertsCard() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [emailNotifications, setEmailNotifications] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.preferences?.email_notifications) {
|
||||||
|
setEmailNotifications(user.preferences.email_notifications)
|
||||||
|
} else {
|
||||||
|
const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({
|
||||||
|
...acc,
|
||||||
|
[option.key]: true
|
||||||
|
}), {} as Record<string, boolean>)
|
||||||
|
setEmailNotifications(defaults)
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const handleToggle = async (key: string) => {
|
||||||
|
const newState = {
|
||||||
|
...emailNotifications,
|
||||||
|
[key]: !emailNotifications[key]
|
||||||
|
}
|
||||||
|
setEmailNotifications(newState)
|
||||||
|
try {
|
||||||
|
await updateUserPreferences({
|
||||||
|
email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setEmailNotifications(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-2 rounded-lg bg-brand-orange/10">
|
||||||
|
<BellIcon className="w-5 h-5 text-brand-orange" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Security Alerts</h2>
|
||||||
|
<p className="text-sm text-neutral-500">Choose which security events trigger email alerts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{SECURITY_ALERT_OPTIONS.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`flex items-center justify-between p-4 border rounded-xl transition-all duration-200 ${
|
||||||
|
emailNotifications[item.key]
|
||||||
|
? 'bg-orange-50 dark:bg-brand-orange/10 border-brand-orange shadow-sm'
|
||||||
|
: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className={`block text-sm font-medium transition-colors duration-200 ${
|
||||||
|
emailNotifications[item.key] ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className={`block text-xs transition-colors duration-200 ${
|
||||||
|
emailNotifications[item.key] ? 'text-brand-orange/80' : 'text-neutral-500 dark:text-neutral-400'
|
||||||
|
}`}>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(item.key)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||||
|
emailNotifications[item.key] ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
emailNotifications[item.key] ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notification Center Placeholder ---
|
||||||
|
|
||||||
|
function NotificationCenterPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-8 shadow-sm">
|
||||||
|
<div className="text-center max-w-md mx-auto">
|
||||||
|
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
|
||||||
|
<p className="text-sm text-neutral-500 mb-4">
|
||||||
|
View and manage all your notifications in one place.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors"
|
||||||
|
>
|
||||||
|
Open Notification Center
|
||||||
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Wrapper ---
|
||||||
|
|
||||||
|
export default function SettingsModalWrapper() {
|
||||||
|
const { isOpen, closeSettings } = useSettingsModal()
|
||||||
|
|
||||||
|
const sections: SettingsSection[] = [
|
||||||
|
{
|
||||||
|
id: 'pulse',
|
||||||
|
label: 'Pulse Settings',
|
||||||
|
description: 'Profile and preferences',
|
||||||
|
icon: UserIcon,
|
||||||
|
defaultExpanded: true,
|
||||||
|
items: [
|
||||||
|
{ id: 'profile', label: 'Profile', content: <ProfileSettings activeTab="profile" /> },
|
||||||
|
{ id: 'security', label: 'Security', content: <ProfileSettings activeTab="security" /> },
|
||||||
|
{ id: 'preferences', label: 'Preferences', content: <ProfileSettings activeTab="preferences" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
label: 'Notifications',
|
||||||
|
description: 'Email and in-app notifications',
|
||||||
|
icon: BellIcon,
|
||||||
|
items: [
|
||||||
|
{ id: 'security-alerts', label: 'Security Alerts', content: <SecurityAlertsCard /> },
|
||||||
|
{ id: 'center', label: 'Notification Center', content: <NotificationCenterPlaceholder /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'account',
|
||||||
|
label: 'Ciphera Account',
|
||||||
|
description: 'Security, 2FA, and sessions',
|
||||||
|
icon: LockIcon,
|
||||||
|
items: [
|
||||||
|
{ id: 'auth-profile', label: 'Profile & Personal Info', href: 'https://auth.ciphera.net/settings', external: true },
|
||||||
|
{ id: 'auth-security', label: 'Security & 2FA', href: 'https://auth.ciphera.net/settings?tab=security', external: true },
|
||||||
|
{ id: 'auth-sessions', label: 'Active Sessions', href: 'https://auth.ciphera.net/settings?tab=sessions', external: true },
|
||||||
|
{ id: 'devices', label: 'Trusted Devices', content: <TrustedDevicesCard /> },
|
||||||
|
{ id: 'activity', label: 'Security Activity', content: <SecurityActivityCard /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return <SettingsModal open={isOpen} onClose={closeSettings} sections={sections} />
|
||||||
|
}
|
||||||
31
lib/settings-modal-context.tsx
Normal file
31
lib/settings-modal-context.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface SettingsModalContextType {
|
||||||
|
isOpen: boolean
|
||||||
|
openSettings: () => void
|
||||||
|
closeSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsModalContext = createContext<SettingsModalContextType>({
|
||||||
|
isOpen: false,
|
||||||
|
openSettings: () => {},
|
||||||
|
closeSettings: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function SettingsModalProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const openSettings = useCallback(() => setIsOpen(true), [])
|
||||||
|
const closeSettings = useCallback(() => setIsOpen(false), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsModalContext.Provider value={{ isOpen, openSettings, closeSettings }}>
|
||||||
|
{children}
|
||||||
|
</SettingsModalContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsModal() {
|
||||||
|
return useContext(SettingsModalContext)
|
||||||
|
}
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.13.0-alpha",
|
"version": "0.13.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.0.80",
|
"@ciphera-net/ui": "^0.0.88",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
@@ -1665,9 +1665,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ciphera-net/ui": {
|
"node_modules/@ciphera-net/ui": {
|
||||||
"version": "0.0.80",
|
"version": "0.0.88",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.80/0fc38c4004f3d899afffa8c15addc490b72efe15",
|
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.88/70285ac19f9349fd13b4cbedf59612bc9c6ecc7e",
|
||||||
"integrity": "sha512-/Jc0aTEE94YA+V/FbpCK133IYbwp/rPpr1a/pdhIG54OzMzVnaRndaVpNLpmLB2Y7POmRTecJr831L1DUrI4WQ==",
|
"integrity": "sha512-YMfrK8NVfFyS/KqvlnFSm3EmeCnIc5Wb4gO//qqyIt7d1lFGgsQJfQ0xOHoo0oQq0STywdnXPOwaTUWJeUIdMA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.0.80",
|
"@ciphera-net/ui": "^0.0.88",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user