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:
Benjamin Sutter
2026-05-15 12:01:46 +02:00
parent 9a2476c58a
commit 7bed2fec86
15 changed files with 852 additions and 29 deletions
+16
View File
@@ -2,6 +2,10 @@ import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router'
import { LoadingPage, AppErrorBoundary } from './components/ui'
import { AppShell } from './components/layout'
import { ProtectedRoute } from './components/auth'
import { WorkspaceType } from './domain/enums'
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
const Properties = lazy(() => import('./pages/supply/Properties'))
@@ -24,28 +28,40 @@ function App() {
<AppErrorBoundary>
<Suspense fallback={<LoadingPage />}>
<Routes>
{/* Public */}
<Route path="/auth/login" element={<LoginScreen />} />
{/* Protected: auth check only */}
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
{/* Supply Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
<Route path="/supply/properties" element={<Properties />} />
<Route path="/supply/match-center" element={<MatchCenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
</Route>
{/* Demand Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.DEMAND} />}>
<Route path="/demand/ai-search" element={<AISearch />} />
<Route path="/demand/results" element={<Results />} />
<Route path="/demand/compare" element={<Compare />} />
<Route path="/demand/shortlists" element={<Shortlists />} />
</Route>
{/* Operations Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.OPERATIONS} />}>
<Route path="/ops/review-queue" element={<ReviewQueue />} />
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
<Route path="/ops/governance" element={<Governance />} />
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+75
View File
@@ -0,0 +1,75 @@
import { Box, Button, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import { ShieldOff } from 'lucide-react'
import { useNavigate } from 'react-router'
interface AccessDeniedProps {
title?: string
message?: string
onBack?: () => void
sx?: SxProps<Theme>
}
export function AccessDenied({
title = 'Kein Zugriff',
message = 'Sie haben keine Berechtigung, diesen Bereich zu öffnen.',
onBack,
sx,
}: AccessDeniedProps) {
const navigate = useNavigate()
function handleBack() {
if (onBack) {
onBack()
} else {
navigate(-1)
}
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
gap: 2,
p: 4,
...sx,
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: 'rgba(239,68,68,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ShieldOff size={28} color="#ef4444" />
</Box>
<Box sx={{ textAlign: 'center', maxWidth: 400 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{message}
</Typography>
</Box>
<Button
variant="outlined"
size="small"
onClick={handleBack}
sx={{ mt: 1, textTransform: 'none' }}
>
Zurück
</Button>
</Box>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { Box, Chip, Typography } from '@mui/material'
import { UserRole } from '../../domain/enums'
import { authService } from '../../services/authService'
import { useSessionStore } from '../../stores/sessionStore'
const ROLE_LABELS: Record<UserRole, string> = {
[UserRole.SUPER_ADMIN]: 'Super Admin',
[UserRole.ORGANIZATION_ADMIN]: 'Org Admin',
[UserRole.PROPERTY_MANAGER]: 'Prop. Manager',
[UserRole.REVIEWER]: 'Reviewer',
[UserRole.OWNER_VIEWER]: 'Owner Viewer',
[UserRole.DEMAND_USER]: 'Demand User',
}
export function DemoRoleSwitcher() {
const { currentUser } = useSessionStore()
async function handleSwitch(role: UserRole) {
await authService.switchDemoRole(role)
}
return (
<Box sx={{ px: 1, py: 0.5 }}>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}
>
Demo-Modus
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{Object.values(UserRole).map((role) => {
const active = currentUser?.role === role
return (
<Chip
key={role}
label={ROLE_LABELS[role]}
size="small"
clickable
onClick={() => handleSwitch(role)}
sx={{
fontSize: '0.7rem',
height: 22,
bgcolor: active ? '#1e3a5f' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
)
})}
</Box>
</Box>
)
}
@@ -0,0 +1,40 @@
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
import type { SelectChangeEvent } from '@mui/material'
import { authService } from '../../services/authService'
import { useSessionStore } from '../../stores/sessionStore'
const MOCK_ORGANIZATIONS = [
{ id: 'org-wincasa', name: 'Wincasa AG' },
{ id: 'org-mobimo', name: 'Mobimo Management AG' },
{ id: 'org-ubs', name: 'UBS Asset Management RE' },
]
export function OrganizationSwitcher() {
const { activeOrganizationId } = useSessionStore()
async function handleChange(e: SelectChangeEvent<string>) {
await authService.switchOrganization(e.target.value)
}
return (
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.5, display: 'block' }}
>
Organisation
</Typography>
<Select
value={activeOrganizationId ?? ''}
onChange={handleChange}
sx={{ fontSize: '0.8rem' }}
>
{MOCK_ORGANIZATIONS.map((org) => (
<MenuItem key={org.id} value={org.id} sx={{ fontSize: '0.8rem' }}>
{org.name}
</MenuItem>
))}
</Select>
</FormControl>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import type { MockUser } from '../../stores/sessionStore'
import { useSessionStore } from '../../stores/sessionStore'
interface PermissionGateProps {
check: (user: MockUser) => boolean
fallback?: ReactNode
children: ReactNode
}
export function PermissionGate({ check, fallback = null, children }: PermissionGateProps) {
const { currentUser } = useSessionStore()
if (!currentUser || !check(currentUser)) {
return <>{fallback}</>
}
return <>{children}</>
}
+38
View File
@@ -0,0 +1,38 @@
import { Navigate, Outlet } from 'react-router'
import type { WorkspaceType } from '../../domain/enums'
import { useSessionStore } from '../../stores/sessionStore'
import { SessionStatus } from '../../stores/sessionStore'
import { canAccessWorkspace } from '../../lib/permissions'
import { AccessDenied } from './AccessDenied'
import { SessionExpired } from './SessionExpired'
interface ProtectedRouteProps {
workspace?: WorkspaceType
}
export function ProtectedRoute({ workspace }: ProtectedRouteProps) {
const { isAuthenticated, currentUser, sessionStatus } = useSessionStore()
if (!isAuthenticated || sessionStatus === SessionStatus.UNAUTHENTICATED) {
return <Navigate to="/auth/login" replace />
}
if (sessionStatus === SessionStatus.EXPIRED) {
return <SessionExpired />
}
if (workspace && currentUser && !canAccessWorkspace(currentUser, workspace)) {
const workspaceLabel: Record<string, string> = {
SUPPLY: 'Supply',
DEMAND: 'Demand',
OPERATIONS: 'Operations',
}
return (
<AccessDenied
message={`Sie haben keine Berechtigung für den ${workspaceLabel[workspace] ?? workspace}-Bereich.`}
/>
)
}
return <Outlet />
}
+20
View File
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import type { UserRole } from '../../domain/enums'
import { useSessionStore } from '../../stores/sessionStore'
import { AccessDenied } from './AccessDenied'
interface RoleGuardProps {
roles: UserRole[]
fallback?: ReactNode
children: ReactNode
}
export function RoleGuard({ roles, fallback, children }: RoleGuardProps) {
const { currentUser } = useSessionStore()
if (!currentUser || !roles.includes(currentUser.role)) {
return <>{fallback ?? <AccessDenied />}</>
}
return <>{children}</>
}
+60
View File
@@ -0,0 +1,60 @@
import { Box, Button, Typography } from '@mui/material'
import { Clock } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useSessionStore } from '../../stores/sessionStore'
export function SessionExpired() {
const { logout } = useSessionStore()
const navigate = useNavigate()
function handleRelogin() {
logout()
navigate('/auth/login')
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
bgcolor: '#f8fafc',
p: 4,
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: 'rgba(245,158,11,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Clock size={28} color="#f59e0b" />
</Box>
<Box sx={{ textAlign: 'center', maxWidth: 380 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
Sitzung abgelaufen
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.
</Typography>
</Box>
<Button
variant="contained"
onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
>
Erneut anmelden
</Button>
</Box>
)
}
+7
View File
@@ -0,0 +1,7 @@
export { AccessDenied } from './AccessDenied'
export { SessionExpired } from './SessionExpired'
export { PermissionGate } from './PermissionGate'
export { RoleGuard } from './RoleGuard'
export { ProtectedRoute } from './ProtectedRoute'
export { DemoRoleSwitcher } from './DemoRoleSwitcher'
export { OrganizationSwitcher } from './OrganizationSwitcher'
+6 -1
View File
@@ -156,6 +156,7 @@ const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
interface SidebarProps {
collapsed: boolean
activeWorkspace: WorkspaceType
allowedWorkspaces: WorkspaceType[]
onWorkspaceClick: (workspace: WorkspaceType) => void
onToggle: () => void
userName: string
@@ -165,6 +166,7 @@ interface SidebarProps {
function Sidebar({
collapsed,
activeWorkspace,
allowedWorkspaces,
onWorkspaceClick,
onToggle,
userName,
@@ -172,6 +174,7 @@ function Sidebar({
}: SidebarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const width = collapsed ? 60 : 240
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
return (
<Box
@@ -240,7 +243,7 @@ function Sidebar({
flexShrink: 0,
}}
>
{WORKSPACE_ORDER.map((ws) => {
{visibleWorkspaces.map((ws) => {
const wsConfig = WORKSPACE_CONFIG[ws]
const Icon = wsConfig.icon
const isActive = ws === activeWorkspace
@@ -506,12 +509,14 @@ export function AppShell() {
const userName = currentUser?.name ?? 'User'
const orgName = currentUser?.organizationName ?? ''
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
return (
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Sidebar
collapsed={sidebarCollapsed}
activeWorkspace={activeWorkspace}
allowedWorkspaces={allowedWorkspaces}
onWorkspaceClick={handleWorkspaceClick}
onToggle={toggleSidebar}
userName={userName}
+34 -5
View File
@@ -1,8 +1,18 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { Avatar, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
import { LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
ORGANIZATION_ADMIN: 'Org Admin',
PROPERTY_MANAGER: 'Property Manager',
REVIEWER: 'Reviewer',
OWNER_VIEWER: 'Owner Viewer',
DEMAND_USER: 'Demand User',
}
function getUserInitials(name: string): string {
return name
@@ -29,7 +39,7 @@ export function UserMenu() {
function handleLogout() {
handleClose()
logout()
navigate('/')
navigate('/auth/login')
}
const initials = getUserInitials(currentUser?.name ?? 'U')
@@ -46,15 +56,25 @@ export function UserMenu() {
anchorEl={anchorEl}
open={!!anchorEl}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 200, mt: 1 } } }}
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
{/* User info header */}
{currentUser && (
<MenuItem disabled sx={{ opacity: '1 !important' }}>
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
</MenuItem>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{ROLE_LABELS[currentUser.role] ?? currentUser.role}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{currentUser.organizationName}
</Typography>
</Box>
)}
<Divider />
<MenuItem onClick={handleClose}>
<ListItemIcon><User size={16} /></ListItemIcon>
Profil
@@ -63,7 +83,16 @@ export function UserMenu() {
<ListItemIcon><Settings size={16} /></ListItemIcon>
Einstellungen
</MenuItem>
<Divider />
{/* Demo role switcher */}
<Box sx={{ px: 1, py: 1 }}>
<DemoRoleSwitcher />
</Box>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon><LogOut size={16} /></ListItemIcon>
Abmelden
+132
View File
@@ -0,0 +1,132 @@
import type { MockUser } from '../stores/sessionStore'
import { UserRole, WorkspaceType, ResultType } from '../domain/enums'
// ── Permission Strings ────────────────────────────────────────────────────────
export const Permission = {
SUPPLY_VIEW: 'supply:view',
SUPPLY_EDIT: 'supply:edit',
DEMAND_VIEW: 'demand:view',
DEMAND_EDIT: 'demand:edit',
DEMAND_REQUEST_CONTACT: 'demand:request_contact',
OPS_VIEW: 'ops:view',
OPS_REVIEW: 'ops:review',
OPS_APPROVE: 'ops:approve',
FUTURE_SIGNAL_VIEW: 'future_signal:view',
FUTURE_SIGNAL_REVIEW: 'future_signal:review',
CONTACT_RELEASE_REQUEST: 'contact_release:request',
CONTACT_RELEASE_APPROVE: 'contact_release:approve',
} as const
export type Permission = typeof Permission[keyof typeof Permission]
// ── Role → Permission Matrix ──────────────────────────────────────────────────
const ALL_PERMISSIONS = Object.values(Permission)
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.SUPER_ADMIN]: ALL_PERMISSIONS,
[UserRole.ORGANIZATION_ADMIN]: ALL_PERMISSIONS,
[UserRole.PROPERTY_MANAGER]: [
Permission.SUPPLY_VIEW,
Permission.SUPPLY_EDIT,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
Permission.CONTACT_RELEASE_APPROVE,
],
[UserRole.REVIEWER]: [
Permission.OPS_VIEW,
Permission.OPS_REVIEW,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
],
[UserRole.OWNER_VIEWER]: [
Permission.SUPPLY_VIEW,
Permission.CONTACT_RELEASE_APPROVE,
],
[UserRole.DEMAND_USER]: [
Permission.DEMAND_VIEW,
Permission.DEMAND_EDIT,
Permission.DEMAND_REQUEST_CONTACT,
Permission.CONTACT_RELEASE_REQUEST,
],
}
// ── Role → Workspace Access ───────────────────────────────────────────────────
const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
[WorkspaceType.SUPPLY]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.PROPERTY_MANAGER,
UserRole.OWNER_VIEWER,
],
[WorkspaceType.DEMAND]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.DEMAND_USER,
],
[WorkspaceType.OPERATIONS]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.REVIEWER,
],
}
// ── Core Functions ────────────────────────────────────────────────────────────
export function getPermissions(user: MockUser): Permission[] {
return ROLE_PERMISSIONS[user.role] ?? []
}
export function hasPermission(user: MockUser, permission: Permission): boolean {
return getPermissions(user).includes(permission)
}
export function canAccessWorkspace(user: MockUser, workspace: WorkspaceType): boolean {
return WORKSPACE_ROLES[workspace].includes(user.role)
}
export function getAccessibleWorkspaces(role: UserRole): WorkspaceType[] {
return Object.entries(WORKSPACE_ROLES)
.filter(([, roles]) => roles.includes(role))
.map(([ws]) => ws as WorkspaceType)
}
// ── Domain Permission Functions ───────────────────────────────────────────────
export function canViewProperty(
user: MockUser,
property: { organizationId: string; resultType: ResultType },
): boolean {
if (user.role === UserRole.SUPER_ADMIN) return true
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
return property.organizationId === user.organizationId
}
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
}
return false
}
export function canViewMatch(
user: MockUser,
match: { organizationId?: string },
): boolean {
if (user.role === UserRole.SUPER_ADMIN || user.role === UserRole.ORGANIZATION_ADMIN) return true
if (user.role === UserRole.PROPERTY_MANAGER || user.role === UserRole.REVIEWER) return true
if (user.role === UserRole.DEMAND_USER) return match.organizationId === user.organizationId
return false // OWNER_VIEWER: only released matches — handled at component level
}
export function canReviewFutureSignal(user: MockUser): boolean {
return hasPermission(user, Permission.FUTURE_SIGNAL_REVIEW)
}
export function canApproveContactRelease(user: MockUser): boolean {
return hasPermission(user, Permission.CONTACT_RELEASE_APPROVE)
}
export function canRequestContactRelease(user: MockUser): boolean {
return hasPermission(user, Permission.CONTACT_RELEASE_REQUEST)
}
+201
View File
@@ -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>
)
}
+103 -2
View File
@@ -1,31 +1,132 @@
import { useSessionStore } from '../stores/sessionStore'
import type { MockUser } from '../stores/sessionStore'
import type { ItemResponse } from './types'
import { UserRole } from '../domain/enums'
import { UserRole, WorkspaceType } from '../domain/enums'
import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions'
import type { Permission } from '../lib/permissions'
// Mock organizations for org switching
const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [
{ id: 'org-wincasa', name: 'Wincasa AG' },
{ id: 'org-mobimo', name: 'Mobimo Management AG' },
{ id: 'org-ubs', name: 'UBS Asset Management RE' },
]
// Demo user presets per role
const DEMO_USERS: Record<UserRole, MockUser> = {
[UserRole.SUPER_ADMIN]: {
id: 'user-super',
email: 'super@ideal-sharing.ch',
name: 'Super Admin',
role: UserRole.SUPER_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
},
[UserRole.ORGANIZATION_ADMIN]: {
id: 'user-001',
email: 'admin@ideal-sharing.ch',
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
},
[UserRole.PROPERTY_MANAGER]: {
id: 'user-pm',
email: 'pm@ideal-sharing.ch',
name: 'Property Manager',
role: UserRole.PROPERTY_MANAGER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.PROPERTY_MANAGER),
},
[UserRole.REVIEWER]: {
id: 'user-rev',
email: 'reviewer@ideal-sharing.ch',
name: 'Reviewer',
role: UserRole.REVIEWER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.REVIEWER),
},
[UserRole.OWNER_VIEWER]: {
id: 'user-ov',
email: 'owner@ideal-sharing.ch',
name: 'Owner Viewer',
role: UserRole.OWNER_VIEWER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.OWNER_VIEWER),
},
[UserRole.DEMAND_USER]: {
id: 'user-dem',
email: 'demand@ideal-sharing.ch',
name: 'Demand User',
role: UserRole.DEMAND_USER,
organizationId: 'org-mobimo',
organizationName: 'Mobimo Management AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.DEMAND_USER),
},
}
export const authService = {
async getCurrentUser(): Promise<ItemResponse<MockUser | null>> {
const data = useSessionStore.getState().currentUser
return { data }
},
async getCurrentOrganization(): Promise<ItemResponse<{ id: string; name: string } | null>> {
const { activeOrganizationId } = useSessionStore.getState()
const org = MOCK_ORGANIZATIONS.find((o) => o.id === activeOrganizationId) ?? null
return { data: org }
},
async login(email: string, _password: string): Promise<ItemResponse<MockUser>> {
const user: MockUser = {
const existing = Object.values(DEMO_USERS).find((u) => u.email === email)
const user: MockUser = existing ?? {
id: 'user-001',
email,
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
}
useSessionStore.getState().login(user)
return { data: user }
},
async logout(): Promise<ItemResponse<void>> {
useSessionStore.getState().logout()
return { data: undefined }
},
async isAuthenticated(): Promise<ItemResponse<boolean>> {
const data = useSessionStore.getState().isAuthenticated
return { data }
},
async switchDemoRole(role: UserRole): Promise<ItemResponse<MockUser>> {
const user = DEMO_USERS[role]
useSessionStore.getState().login(user)
return { data: user }
},
async switchOrganization(organizationId: string): Promise<ItemResponse<void>> {
const org = MOCK_ORGANIZATIONS.find((o) => o.id === organizationId)
if (org) {
const state = useSessionStore.getState()
if (state.currentUser) {
state.login({ ...state.currentUser, organizationId: org.id, organizationName: org.name })
} else {
state.switchOrganization(organizationId)
}
}
return { data: undefined }
},
async getPermissions(user: MockUser): Promise<ItemResponse<Permission[]>> {
return { data: getPermissions(user) }
},
}
+28 -3
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import { UserRole } from '../domain/enums'
import { UserRole, WorkspaceType } from '../domain/enums'
export interface MockUser {
id: string
@@ -8,15 +8,27 @@ export interface MockUser {
role: UserRole
organizationId: string
organizationName: string
allowedWorkspaces: WorkspaceType[]
}
export const SessionStatus = {
UNAUTHENTICATED: 'unauthenticated',
AUTHENTICATED: 'authenticated',
EXPIRED: 'expired',
RESTRICTED: 'restricted',
ONBOARDING: 'onboarding',
} as const
export type SessionStatus = typeof SessionStatus[keyof typeof SessionStatus]
interface SessionState {
currentUser: MockUser | null
activeOrganizationId: string | null
isAuthenticated: boolean
sessionStatus: SessionStatus
login: (user: MockUser) => void
logout: () => void
switchOrganization: (organizationId: string) => void
setSessionStatus: (status: SessionStatus) => void
}
const mockUser: MockUser = {
@@ -26,13 +38,26 @@ const mockUser: MockUser = {
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
}
export const useSessionStore = create<SessionState>((set) => ({
currentUser: mockUser,
activeOrganizationId: mockUser.organizationId,
isAuthenticated: true,
login: (user) => set({ currentUser: user, activeOrganizationId: user.organizationId, isAuthenticated: true }),
logout: () => set({ currentUser: null, activeOrganizationId: null, isAuthenticated: false }),
sessionStatus: SessionStatus.AUTHENTICATED,
login: (user) => set({
currentUser: user,
activeOrganizationId: user.organizationId,
isAuthenticated: true,
sessionStatus: SessionStatus.AUTHENTICATED,
}),
logout: () => set({
currentUser: null,
activeOrganizationId: null,
isAuthenticated: false,
sessionStatus: SessionStatus.UNAUTHENTICATED,
}),
switchOrganization: (organizationId) => set({ activeOrganizationId: organizationId }),
setSessionStatus: (status) => set({ sessionStatus: status }),
}))