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:
Bernt
2026-08-10 12:52:48 +00:00
parent 8921fd1467
commit 78b57273e2
141 changed files with 29192 additions and 180 deletions
@@ -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>
)
}
+49
View File
@@ -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>
)
}
+59
View File
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}