security: Add proper authentication, RBAC, and tenant isolation
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DarkModeToggle() {
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const saved = localStorage.getItem('amos-dark-mode')
|
||||
const isDark = saved ? saved === 'true' : prefersDark
|
||||
setDarkMode(isDark)
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const newMode = !darkMode
|
||||
setDarkMode(newMode)
|
||||
localStorage.setItem('amos-dark-mode', String(newMode))
|
||||
|
||||
if (newMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors',
|
||||
darkMode
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-bg text-text-secondary hover:text-text-primary'
|
||||
)}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Haptics } from '@/lib/haptics'
|
||||
|
||||
interface GestureNavProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function GestureNav({ children }: GestureNavProps) {
|
||||
const navigate = useNavigate()
|
||||
const lastTap = useRef<number>(0)
|
||||
const tapCount = useRef<number>(0)
|
||||
const tapTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleTap = useCallback((_e: React.TouchEvent) => {
|
||||
const now = Date.now()
|
||||
const timeDiff = now - lastTap.current
|
||||
|
||||
if (timeDiff < 300) {
|
||||
// Double tap detected
|
||||
tapCount.current += 1
|
||||
|
||||
if (tapCount.current === 2) {
|
||||
// Triple tap - go to dashboard
|
||||
Haptics.medium()
|
||||
navigate('/dashboard')
|
||||
tapCount.current = 0
|
||||
}
|
||||
} else {
|
||||
tapCount.current = 1
|
||||
}
|
||||
|
||||
lastTap.current = now
|
||||
|
||||
// Reset tap count after delay
|
||||
if (tapTimer.current) {
|
||||
clearTimeout(tapTimer.current)
|
||||
}
|
||||
tapTimer.current = setTimeout(() => {
|
||||
tapCount.current = 0
|
||||
}, 500)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div onTouchEnd={handleTap}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MobileCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function MobileCard({ children, className, onClick }: MobileCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'bg-surface rounded-2xl p-4 card-shadow border border-border/40',
|
||||
onClick && 'active:scale-[0.98] transition-transform cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileCardRowProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileCardRow({ label, value, className }: MobileCardRowProps) {
|
||||
return (
|
||||
<div className={cn('flex justify-between items-center py-2', className)}>
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileCardBadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileCardBadge({ children, variant = 'default', className }: MobileCardBadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-bg text-text-secondary',
|
||||
success: 'bg-success-light text-success',
|
||||
warning: 'bg-warning-light text-warning',
|
||||
danger: 'bg-danger-light text-danger',
|
||||
info: 'bg-primary-light text-primary',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('text-xs font-medium px-2.5 py-1 rounded-full', variants[variant], className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface MobileChartProps {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: number
|
||||
changeLabel?: string
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger'
|
||||
sparklineData?: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileChart({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changeLabel,
|
||||
color = 'primary',
|
||||
sparklineData,
|
||||
className,
|
||||
}: MobileChartProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const colors = {
|
||||
primary: 'text-primary bg-primary-light',
|
||||
success: 'text-success bg-success-light',
|
||||
warning: 'text-warning bg-warning-light',
|
||||
danger: 'text-danger bg-danger-light',
|
||||
}
|
||||
|
||||
const sparklineColor = {
|
||||
primary: '#2563EB',
|
||||
success: '#16A34A',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
}
|
||||
|
||||
// Simple SVG sparkline
|
||||
const renderSparkline = () => {
|
||||
if (!sparklineData || sparklineData.length < 2) return null
|
||||
|
||||
const width = 120
|
||||
const height = 40
|
||||
const max = Math.max(...sparklineData)
|
||||
const min = Math.min(...sparklineData)
|
||||
const range = max - min || 1
|
||||
|
||||
const points = sparklineData.map((v, i) => {
|
||||
const x = (i / (sparklineData.length - 1)) * width
|
||||
const y = height - ((v - min) / range) * height
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="mt-2">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={sparklineColor[color]}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface rounded-2xl p-4 card-shadow border border-border/40',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary uppercase tracking-wider">{title}</p>
|
||||
<p className="text-2xl font-semibold text-text-primary mt-1">{value}</p>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={cn('text-xs font-medium', change >= 0 ? 'text-success' : 'text-danger')}>
|
||||
{change >= 0 ? '+' : ''}{change}%
|
||||
</span>
|
||||
{changeLabel && (
|
||||
<span className="text-xs text-text-secondary">{changeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||
colors[color]
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && sparklineData && (
|
||||
<div className="mt-3 pt-3 border-t border-border/40">
|
||||
{renderSparkline()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface PullToRefreshProps {
|
||||
onRefresh: () => Promise<void>
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
||||
const [pulling, setPulling] = useState(false)
|
||||
const [pullDistance, setPullDistance] = useState(0)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const touchStartY = useRef(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const maxPullDistance = 100
|
||||
const refreshThreshold = 80
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// Only allow pull-to-refresh when at top of scroll
|
||||
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
||||
touchStartY.current = e.targetTouches[0].clientY
|
||||
setPulling(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!pulling) return
|
||||
|
||||
const currentY = e.targetTouches[0].clientY
|
||||
const diff = currentY - touchStartY.current
|
||||
|
||||
if (diff > 0) {
|
||||
// Resistance increases as user pulls further
|
||||
const resistance = 1 + (diff / maxPullDistance) * 0.5
|
||||
const newDistance = Math.min(diff / resistance, maxPullDistance)
|
||||
setPullDistance(newDistance)
|
||||
}
|
||||
}, [pulling])
|
||||
|
||||
const onTouchEnd = useCallback(async () => {
|
||||
if (!pulling) return
|
||||
|
||||
if (pullDistance >= refreshThreshold && !refreshing) {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefresh()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
setPulling(false)
|
||||
setPullDistance(0)
|
||||
}, [pulling, pullDistance, refreshing, onRefresh])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative overflow-y-auto', className)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Pull indicator */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 flex items-center justify-center transition-transform"
|
||||
style={{
|
||||
transform: `translateY(${pullDistance - 60}px)`,
|
||||
opacity: pulling ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw
|
||||
size={24}
|
||||
className={cn(
|
||||
'text-primary transition-transform',
|
||||
refreshing && 'animate-spin',
|
||||
!refreshing && pullDistance >= refreshThreshold && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{refreshing ? 'Refreshing...' : pullDistance >= refreshThreshold ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content with offset when pulling */}
|
||||
<div
|
||||
style={{
|
||||
transform: pulling ? `translateY(${pullDistance}px)` : 'translateY(0)',
|
||||
transition: pulling ? 'none' : 'transform 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { MobileCard, MobileCardRow } from './MobileCard'
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render: (item: T) => React.ReactNode
|
||||
mobile?: boolean // show on mobile?
|
||||
}
|
||||
|
||||
interface ResponsiveTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
onRowClick?: (item: T) => void
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
export function ResponsiveTable<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
title,
|
||||
subtitle,
|
||||
onRowClick,
|
||||
emptyMessage = 'No data',
|
||||
}: ResponsiveTableProps<T>) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const newSet = new Set(expandedRows)
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id)
|
||||
} else {
|
||||
newSet.add(id)
|
||||
}
|
||||
setExpandedRows(newSet)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-secondary">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="py-3 px-4 text-xs font-medium text-text-secondary uppercase tracking-wider text-left"
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors hover:bg-bg/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="py-3.5 px-4 text-sm text-text-primary">
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{title && <h3 className="text-lg font-semibold mb-2">{title}</h3>}
|
||||
{subtitle && <p className="text-sm text-text-secondary mb-4">{subtitle}</p>}
|
||||
{data.map((item) => {
|
||||
const id = keyExtractor(item)
|
||||
const isExpanded = expandedRows.has(id)
|
||||
const mobileColumns = columns.filter((c) => c.mobile !== false)
|
||||
const primaryCol = mobileColumns[0]
|
||||
const secondaryCols = mobileColumns.slice(1)
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={id}
|
||||
onClick={() => {
|
||||
if (secondaryCols.length > 2) {
|
||||
toggleRow(id)
|
||||
} else {
|
||||
onRowClick?.(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary">
|
||||
{primaryCol?.render(item)}
|
||||
</div>
|
||||
{secondaryCols.length <= 2 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{secondaryCols.slice(0, 2).map((col) => (
|
||||
<span key={col.key} className="text-xs text-text-secondary">
|
||||
{col.header}: {col.render(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{secondaryCols.length > 2 && (
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
'text-text-secondary transition-transform',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && secondaryCols.length > 2 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50 space-y-1">
|
||||
{secondaryCols.map((col) => (
|
||||
<MobileCardRow
|
||||
key={col.key}
|
||||
label={col.header}
|
||||
value={col.render(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MobileCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRef, useState, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Haptics } from '@/lib/haptics'
|
||||
|
||||
interface SwipeContainerProps {
|
||||
children: React.ReactNode
|
||||
onSwipeLeft?: () => void
|
||||
onSwipeRight?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SwipeContainer({ children, onSwipeLeft, onSwipeRight, className }: SwipeContainerProps) {
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
const touchEnd = useRef<{ x: number; y: number } | null>(null)
|
||||
const [swiping, setSwiping] = useState(false)
|
||||
|
||||
const minSwipeDistance = 50
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = null
|
||||
touchStart.current = { x: e.targetTouches[0].clientX, y: e.targetTouches[0].clientY }
|
||||
setSwiping(true)
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = { x: e.targetTouches[0].clientX, y: e.targetTouches[0].clientY }
|
||||
}, [])
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
setSwiping(false)
|
||||
if (!touchStart.current || !touchEnd.current) return
|
||||
|
||||
const distanceX = touchStart.current.x - touchEnd.current.x
|
||||
const distanceY = touchStart.current.y - touchEnd.current.y
|
||||
const isHorizontalSwipe = Math.abs(distanceX) > Math.abs(distanceY)
|
||||
|
||||
if (isHorizontalSwipe && Math.abs(distanceX) > minSwipeDistance) {
|
||||
Haptics.swipe()
|
||||
if (distanceX > 0) {
|
||||
onSwipeLeft?.()
|
||||
} else {
|
||||
onSwipeRight?.()
|
||||
}
|
||||
}
|
||||
|
||||
touchStart.current = null
|
||||
touchEnd.current = null
|
||||
}, [onSwipeLeft, onSwipeRight])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('touch-pan-y', swiping && 'select-none', className)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user