feat: F002 authentication & session layer
Add permission matrix, ProtectedRoute, RoleGuard, PermissionGate, DemoRoleSwitcher, OrganizationSwitcher, AccessDenied, SessionExpired, and institutional LoginScreen. Wire workspace-level route protection into App.tsx; sidebar filters tabs by allowedWorkspaces. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Navigate } from 'react-router'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { authService } from '../../services/authService'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { UserRole } from '../../domain/enums'
|
||||
|
||||
const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [
|
||||
{ role: UserRole.ORGANIZATION_ADMIN, label: 'Org Admin', description: 'Vollzugriff Supply + Demand + Ops' },
|
||||
{ role: UserRole.PROPERTY_MANAGER, label: 'Property Manager', description: 'Supply Workspace' },
|
||||
{ role: UserRole.DEMAND_USER, label: 'Demand User', description: 'Demand Workspace' },
|
||||
{ role: UserRole.REVIEWER, label: 'Reviewer', description: 'Operations Workspace' },
|
||||
{ role: UserRole.OWNER_VIEWER, label: 'Owner Viewer', description: 'Supply (eingeschränkt)' },
|
||||
{ role: UserRole.SUPER_ADMIN, label: 'Super Admin', description: 'Plattform-Administrator' },
|
||||
]
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { isAuthenticated } = useSessionStore()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('admin@ideal-sharing.ch')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!email) {
|
||||
setError('Bitte E-Mail-Adresse eingeben.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await authService.login(email, password)
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDemoLogin(role: UserRole) {
|
||||
setLoading(true)
|
||||
try {
|
||||
await authService.switchDemoRole(role)
|
||||
navigate('/')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: '#f1f5f9',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 480 }}>
|
||||
{/* Branding */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3, justifyContent: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: '#0f1923',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Building2 size={22} color="#64b5f6" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', color: '#0f1923', lineHeight: 1.2 }}>
|
||||
Property Match
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Decision Intelligence
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Login card */}
|
||||
<Card elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 3 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>Anmelden</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 3 }}>
|
||||
Melden Sie sich mit Ihren Zugangsdaten an.
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleLogin} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
helperText="Im Demo-Modus wird jedes Passwort akzeptiert."
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Typography variant="caption" sx={{ color: 'error.main' }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', fontWeight: 600, '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
{loading ? <CircularProgress size={20} sx={{ color: '#fff' }} /> : 'Anmelden'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Demo access */}
|
||||
<Card elevation={0} sx={{ border: '1px solid #e2e8f0' }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Chip label="Demo" size="small" sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Demo-Zugänge
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{DEMO_ROLES.map(({ role, label, description }) => (
|
||||
<Box
|
||||
key={role}
|
||||
onClick={() => handleDemoLogin(role)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: '#1e3a5f', bgcolor: 'rgba(30,58,95,0.03)' },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>→</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user