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:
+16
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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) }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user