|
15 | 15 | 'use client' |
16 | 16 |
|
17 | 17 | import { useEffect, useRef, useState } from 'react' |
18 | | -import { AnimatePresence, motion } from 'framer-motion' |
| 18 | +import { AnimatePresence, motion, useInView } from 'framer-motion' |
19 | 19 | import Image from 'next/image' |
20 | 20 | import Link from 'next/link' |
21 | 21 | import { Badge, ChevronDown } from '@/components/emcn' |
22 | 22 | import { Lock } from '@/components/emcn/icons' |
23 | 23 | import { GithubIcon } from '@/components/icons' |
| 24 | +import { PROVIDER_DEFINITIONS } from '@/providers/models' |
24 | 25 |
|
25 | 26 | /** Consistent color per actor — same pattern as Collaboration section cursors. */ |
26 | 27 | const ACTOR_COLORS: Record<string, string> = { |
@@ -216,21 +217,13 @@ function AuditRow({ entry, index }: AuditRowProps) { |
216 | 217 | {timeAgo} |
217 | 218 | </span> |
218 | 219 |
|
219 | | - {/* Description — description hidden on mobile to avoid truncation */} |
220 | 220 | <span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'> |
221 | 221 | <span className='text-[#F6F6F6]/80'>{entry.actor}</span> |
222 | 222 | <span className='hidden sm:inline'> |
223 | 223 | <span className='text-[#F6F6F6]/40'> · </span> |
224 | 224 | <span className='text-[#F6F6F6]/55'>{entry.description}</span> |
225 | 225 | </span> |
226 | 226 | </span> |
227 | | - |
228 | | - {/* Resource type label — formatted name, neutral so it doesn't compete with actor colors */} |
229 | | - {resourceLabel && ( |
230 | | - <span className='ml-auto shrink-0 rounded border border-[#2A2A2A] px-[7px] py-[3px] font-[430] font-season text-[#F6F6F6]/25 text-[10px] leading-none tracking-[0.04em]'> |
231 | | - {resourceLabel} |
232 | | - </span> |
233 | | - )} |
234 | 227 | </div> |
235 | 228 | </div> |
236 | 229 | ) |
@@ -271,60 +264,202 @@ function AuditLogPreview() { |
271 | 264 | }, []) |
272 | 265 |
|
273 | 266 | return ( |
274 | | - <div className='mx-6 mt-6 overflow-hidden rounded-[8px] border border-[#2A2A2A] md:mx-8 md:mt-8'> |
275 | | - {/* Header */} |
276 | | - <div className='flex items-center justify-between border-[#2A2A2A] border-b bg-[#161616] px-4 py-[10px]'> |
277 | | - <div className='flex items-center gap-2'> |
278 | | - {/* Pulsing live indicator */} |
279 | | - <span className='relative flex h-[8px] w-[8px]'> |
280 | | - <span |
281 | | - className='absolute inline-flex h-full w-full animate-ping rounded-full opacity-50' |
282 | | - style={{ backgroundColor: '#33C482' }} |
283 | | - /> |
284 | | - <span |
285 | | - className='relative inline-flex h-[8px] w-[8px] rounded-full' |
286 | | - style={{ backgroundColor: '#33C482' }} |
287 | | - /> |
288 | | - </span> |
289 | | - <span className='font-[430] font-season text-[#F6F6F6]/40 text-[11px] uppercase tracking-[0.08em]'> |
290 | | - Audit Log |
291 | | - </span> |
292 | | - </div> |
293 | | - <div className='flex items-center gap-2'> |
294 | | - <span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'> |
295 | | - Export |
296 | | - </span> |
297 | | - <span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'> |
298 | | - Filter |
299 | | - </span> |
300 | | - </div> |
301 | | - </div> |
| 267 | + <div className='mt-5 overflow-hidden px-6 md:mt-6 md:px-8'> |
| 268 | + <AnimatePresence mode='popLayout' initial={false}> |
| 269 | + {entries.map((entry, index) => ( |
| 270 | + <motion.div |
| 271 | + key={entry.id} |
| 272 | + layout |
| 273 | + initial={{ y: -48, opacity: 0 }} |
| 274 | + animate={{ y: 0, opacity: 1 }} |
| 275 | + exit={{ opacity: 0 }} |
| 276 | + transition={{ |
| 277 | + layout: { |
| 278 | + type: 'spring', |
| 279 | + stiffness: 380, |
| 280 | + damping: 38, |
| 281 | + mass: 0.8, |
| 282 | + }, |
| 283 | + y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] }, |
| 284 | + opacity: { duration: 0.25 }, |
| 285 | + }} |
| 286 | + > |
| 287 | + <AuditRow entry={entry} index={index} /> |
| 288 | + </motion.div> |
| 289 | + ))} |
| 290 | + </AnimatePresence> |
| 291 | + </div> |
| 292 | + ) |
| 293 | +} |
| 294 | + |
| 295 | +const CHECK_PATH = |
| 296 | + 'M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z' |
| 297 | + |
| 298 | +interface PermissionFeature { |
| 299 | + name: string |
| 300 | + key: string |
| 301 | + defaultEnabled: boolean |
| 302 | + providerId?: string |
| 303 | +} |
| 304 | + |
| 305 | +interface PermissionCategory { |
| 306 | + label: string |
| 307 | + color: string |
| 308 | + features: PermissionFeature[] |
| 309 | +} |
| 310 | + |
| 311 | +const PERMISSION_CATEGORIES: PermissionCategory[] = [ |
| 312 | + { |
| 313 | + label: 'Providers', |
| 314 | + color: '#FA4EDF', |
| 315 | + features: [ |
| 316 | + { key: 'openai', name: 'OpenAI', defaultEnabled: true, providerId: 'openai' }, |
| 317 | + { key: 'anthropic', name: 'Anthropic', defaultEnabled: true, providerId: 'anthropic' }, |
| 318 | + { key: 'google', name: 'Google', defaultEnabled: false, providerId: 'google' }, |
| 319 | + { key: 'xai', name: 'xAI', defaultEnabled: true, providerId: 'xai' }, |
| 320 | + ], |
| 321 | + }, |
| 322 | + { |
| 323 | + label: 'Workspace', |
| 324 | + color: '#2ABBF8', |
| 325 | + features: [ |
| 326 | + { key: 'knowledge-base', name: 'Knowledge Base', defaultEnabled: true }, |
| 327 | + { key: 'tables', name: 'Tables', defaultEnabled: true }, |
| 328 | + { key: 'copilot', name: 'Copilot', defaultEnabled: false }, |
| 329 | + { key: 'environment', name: 'Environment', defaultEnabled: false }, |
| 330 | + ], |
| 331 | + }, |
| 332 | + { |
| 333 | + label: 'Tools', |
| 334 | + color: '#33C482', |
| 335 | + features: [ |
| 336 | + { key: 'mcp-tools', name: 'MCP Tools', defaultEnabled: true }, |
| 337 | + { key: 'custom-tools', name: 'Custom Tools', defaultEnabled: false }, |
| 338 | + { key: 'skills', name: 'Skills', defaultEnabled: true }, |
| 339 | + { key: 'invitations', name: 'Invitations', defaultEnabled: true }, |
| 340 | + ], |
| 341 | + }, |
| 342 | +] |
302 | 343 |
|
303 | | - {/* Log entries — new items push existing ones down */} |
304 | | - <div className='overflow-hidden'> |
305 | | - <AnimatePresence mode='popLayout' initial={false}> |
306 | | - {entries.map((entry, index) => ( |
| 344 | +const INITIAL_ACCESS_STATE = Object.fromEntries( |
| 345 | + PERMISSION_CATEGORIES.flatMap((category) => |
| 346 | + category.features.map((feature) => [feature.key, feature.defaultEnabled]) |
| 347 | + ) |
| 348 | +) |
| 349 | + |
| 350 | +function CheckboxIcon({ checked, color }: { checked: boolean; color: string }) { |
| 351 | + return ( |
| 352 | + <div |
| 353 | + className='h-[6px] w-[6px] shrink-0 rounded-full transition-colors duration-200' |
| 354 | + style={{ |
| 355 | + backgroundColor: checked ? color : 'transparent', |
| 356 | + border: checked ? 'none' : '1.5px solid #3A3A3A', |
| 357 | + }} |
| 358 | + /> |
| 359 | + ) |
| 360 | +} |
| 361 | + |
| 362 | +function ProviderPreviewIcon({ providerId }: { providerId?: string }) { |
| 363 | + if (!providerId) return null |
| 364 | + |
| 365 | + const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon |
| 366 | + if (!ProviderIcon) return null |
| 367 | + |
| 368 | + return ( |
| 369 | + <div className='relative flex h-[14px] w-[14px] shrink-0 items-center justify-center opacity-50 brightness-0 invert'> |
| 370 | + <ProviderIcon className='!h-[14px] !w-[14px]' /> |
| 371 | + </div> |
| 372 | + ) |
| 373 | +} |
| 374 | + |
| 375 | +function AccessControlPanel() { |
| 376 | + const ref = useRef(null) |
| 377 | + const isInView = useInView(ref, { once: true, margin: '-40px' }) |
| 378 | + const [accessState, setAccessState] = useState<Record<string, boolean>>(INITIAL_ACCESS_STATE) |
| 379 | + |
| 380 | + const allFeatures = PERMISSION_CATEGORIES.flatMap((c) => c.features) |
| 381 | + |
| 382 | + return ( |
| 383 | + <div ref={ref}> |
| 384 | + {/* Mobile — single row, subset of features */} |
| 385 | + <div className='flex flex-wrap gap-x-5 gap-y-3 lg:hidden'> |
| 386 | + {allFeatures.slice(0, 8).map((feature, i) => { |
| 387 | + const enabled = accessState[feature.key] |
| 388 | + const category = PERMISSION_CATEGORIES.find((c) => |
| 389 | + c.features.some((f) => f.key === feature.key) |
| 390 | + )! |
| 391 | + |
| 392 | + return ( |
307 | 393 | <motion.div |
308 | | - key={entry.id} |
309 | | - layout |
310 | | - initial={{ y: -48, opacity: 0 }} |
311 | | - animate={{ y: 0, opacity: 1 }} |
312 | | - exit={{ opacity: 0 }} |
313 | | - transition={{ |
314 | | - layout: { |
315 | | - type: 'spring', |
316 | | - stiffness: 380, |
317 | | - damping: 38, |
318 | | - mass: 0.8, |
319 | | - }, |
320 | | - y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] }, |
321 | | - opacity: { duration: 0.25 }, |
322 | | - }} |
| 394 | + key={feature.key} |
| 395 | + className='flex cursor-pointer items-center gap-[8px]' |
| 396 | + initial={{ opacity: 0, x: -6 }} |
| 397 | + animate={isInView ? { opacity: 1, x: 0 } : {}} |
| 398 | + transition={{ delay: 0.05 + i * 0.04, duration: 0.3 }} |
| 399 | + onClick={() => |
| 400 | + setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) |
| 401 | + } |
| 402 | + whileTap={{ scale: 0.98 }} |
323 | 403 | > |
324 | | - <AuditRow entry={entry} index={index} /> |
| 404 | + <CheckboxIcon checked={enabled} color={category.color} /> |
| 405 | + <ProviderPreviewIcon providerId={feature.providerId} /> |
| 406 | + <span |
| 407 | + className='font-[430] font-season text-[13px] leading-none tracking-[0.02em]' |
| 408 | + style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }} |
| 409 | + > |
| 410 | + {feature.name} |
| 411 | + </span> |
325 | 412 | </motion.div> |
326 | | - ))} |
327 | | - </AnimatePresence> |
| 413 | + ) |
| 414 | + })} |
| 415 | + </div> |
| 416 | + |
| 417 | + {/* Desktop — categorized grid */} |
| 418 | + <div className='hidden lg:block'> |
| 419 | + {PERMISSION_CATEGORIES.map((category, catIdx) => ( |
| 420 | + <div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}> |
| 421 | + <span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'> |
| 422 | + {category.label} |
| 423 | + </span> |
| 424 | + <div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'> |
| 425 | + {category.features.map((feature, featIdx) => { |
| 426 | + const enabled = accessState[feature.key] |
| 427 | + const currentIndex = |
| 428 | + PERMISSION_CATEGORIES.slice(0, catIdx).reduce( |
| 429 | + (sum, c) => sum + c.features.length, |
| 430 | + 0 |
| 431 | + ) + featIdx |
| 432 | + |
| 433 | + return ( |
| 434 | + <motion.div |
| 435 | + key={feature.key} |
| 436 | + className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]' |
| 437 | + initial={{ opacity: 0, x: -6 }} |
| 438 | + animate={isInView ? { opacity: 1, x: 0 } : {}} |
| 439 | + transition={{ |
| 440 | + delay: 0.1 + currentIndex * 0.04, |
| 441 | + duration: 0.3, |
| 442 | + ease: [0.25, 0.46, 0.45, 0.94], |
| 443 | + }} |
| 444 | + onClick={() => |
| 445 | + setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) |
| 446 | + } |
| 447 | + whileTap={{ scale: 0.98 }} |
| 448 | + > |
| 449 | + <CheckboxIcon checked={enabled} color={category.color} /> |
| 450 | + <ProviderPreviewIcon providerId={feature.providerId} /> |
| 451 | + <span |
| 452 | + className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200' |
| 453 | + style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }} |
| 454 | + > |
| 455 | + {feature.name} |
| 456 | + </span> |
| 457 | + </motion.div> |
| 458 | + ) |
| 459 | + })} |
| 460 | + </div> |
| 461 | + </div> |
| 462 | + ))} |
328 | 463 | </div> |
329 | 464 | </div> |
330 | 465 | ) |
@@ -420,7 +555,37 @@ export default function Enterprise() { |
420 | 555 | </div> |
421 | 556 |
|
422 | 557 | <div className='mt-8 overflow-hidden rounded-[12px] bg-[#1C1C1C] sm:mt-10 md:mt-12'> |
423 | | - <AuditLogPreview /> |
| 558 | + <div className='grid grid-cols-1 border-[#2A2A2A] border-b lg:grid-cols-[1fr_420px]'> |
| 559 | + {/* Audit Trail */} |
| 560 | + <div className='border-[#2A2A2A] lg:border-r'> |
| 561 | + <div className='px-6 pt-6 md:px-8 md:pt-8'> |
| 562 | + <h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'> |
| 563 | + Audit Trail |
| 564 | + </h3> |
| 565 | + <p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'> |
| 566 | + Every action is captured with full actor attribution. |
| 567 | + </p> |
| 568 | + </div> |
| 569 | + <AuditLogPreview /> |
| 570 | + <div className='h-6 md:h-8' /> |
| 571 | + </div> |
| 572 | + |
| 573 | + {/* Access Control */} |
| 574 | + <div className='border-[#2A2A2A] border-t lg:border-t-0'> |
| 575 | + <div className='px-6 pt-6 md:px-8 md:pt-8'> |
| 576 | + <h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'> |
| 577 | + Access Control |
| 578 | + </h3> |
| 579 | + <p className='mt-[6px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'> |
| 580 | + Restrict providers, surfaces, and tools per group. |
| 581 | + </p> |
| 582 | + </div> |
| 583 | + <div className='mt-5 px-6 pb-6 md:mt-6 md:px-8 md:pb-8'> |
| 584 | + <AccessControlPanel /> |
| 585 | + </div> |
| 586 | + </div> |
| 587 | + </div> |
| 588 | + |
424 | 589 | <TrustStrip /> |
425 | 590 |
|
426 | 591 | {/* Scrolling feature ticker */} |
|
0 commit comments