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:
+34
-18
@@ -2,6 +2,10 @@ import { lazy, Suspense } from 'react'
|
|||||||
import { Routes, Route, Navigate } from 'react-router'
|
import { Routes, Route, Navigate } from 'react-router'
|
||||||
import { LoadingPage, AppErrorBoundary } from './components/ui'
|
import { LoadingPage, AppErrorBoundary } from './components/ui'
|
||||||
import { AppShell } from './components/layout'
|
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 SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
||||||
const Properties = lazy(() => import('./pages/supply/Properties'))
|
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||||
@@ -24,27 +28,39 @@ function App() {
|
|||||||
<AppErrorBoundary>
|
<AppErrorBoundary>
|
||||||
<Suspense fallback={<LoadingPage />}>
|
<Suspense fallback={<LoadingPage />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
{/* Public */}
|
||||||
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
|
<Route path="/auth/login" element={<LoginScreen />} />
|
||||||
|
|
||||||
{/* Supply Workspace */}
|
{/* Protected: auth check only */}
|
||||||
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="/supply/properties" element={<Properties />} />
|
<Route element={<AppShell />}>
|
||||||
<Route path="/supply/match-center" element={<MatchCenter />} />
|
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
|
||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
|
||||||
|
|
||||||
{/* Demand Workspace */}
|
{/* Supply Workspace */}
|
||||||
<Route path="/demand/ai-search" element={<AISearch />} />
|
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
|
||||||
<Route path="/demand/results" element={<Results />} />
|
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
||||||
<Route path="/demand/compare" element={<Compare />} />
|
<Route path="/supply/properties" element={<Properties />} />
|
||||||
<Route path="/demand/shortlists" element={<Shortlists />} />
|
<Route path="/supply/match-center" element={<MatchCenter />} />
|
||||||
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Operations Workspace */}
|
{/* Demand Workspace */}
|
||||||
<Route path="/ops/review-queue" element={<ReviewQueue />} />
|
<Route element={<ProtectedRoute workspace={WorkspaceType.DEMAND} />}>
|
||||||
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
<Route path="/demand/ai-search" element={<AISearch />} />
|
||||||
<Route path="/ops/governance" element={<Governance />} />
|
<Route path="/demand/results" element={<Results />} />
|
||||||
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
|
<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>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -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 {
|
interface SidebarProps {
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
activeWorkspace: WorkspaceType
|
activeWorkspace: WorkspaceType
|
||||||
|
allowedWorkspaces: WorkspaceType[]
|
||||||
onWorkspaceClick: (workspace: WorkspaceType) => void
|
onWorkspaceClick: (workspace: WorkspaceType) => void
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
userName: string
|
userName: string
|
||||||
@@ -165,6 +166,7 @@ interface SidebarProps {
|
|||||||
function Sidebar({
|
function Sidebar({
|
||||||
collapsed,
|
collapsed,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
|
allowedWorkspaces,
|
||||||
onWorkspaceClick,
|
onWorkspaceClick,
|
||||||
onToggle,
|
onToggle,
|
||||||
userName,
|
userName,
|
||||||
@@ -172,6 +174,7 @@ function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const config = WORKSPACE_CONFIG[activeWorkspace]
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||||
const width = collapsed ? 60 : 240
|
const width = collapsed ? 60 : 240
|
||||||
|
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -240,7 +243,7 @@ function Sidebar({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{WORKSPACE_ORDER.map((ws) => {
|
{visibleWorkspaces.map((ws) => {
|
||||||
const wsConfig = WORKSPACE_CONFIG[ws]
|
const wsConfig = WORKSPACE_CONFIG[ws]
|
||||||
const Icon = wsConfig.icon
|
const Icon = wsConfig.icon
|
||||||
const isActive = ws === activeWorkspace
|
const isActive = ws === activeWorkspace
|
||||||
@@ -506,12 +509,14 @@ export function AppShell() {
|
|||||||
|
|
||||||
const userName = currentUser?.name ?? 'User'
|
const userName = currentUser?.name ?? 'User'
|
||||||
const orgName = currentUser?.organizationName ?? ''
|
const orgName = currentUser?.organizationName ?? ''
|
||||||
|
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
activeWorkspace={activeWorkspace}
|
activeWorkspace={activeWorkspace}
|
||||||
|
allowedWorkspaces={allowedWorkspaces}
|
||||||
onWorkspaceClick={handleWorkspaceClick}
|
onWorkspaceClick={handleWorkspaceClick}
|
||||||
onToggle={toggleSidebar}
|
onToggle={toggleSidebar}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
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 { LogOut, Settings, User } from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
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 {
|
function getUserInitials(name: string): string {
|
||||||
return name
|
return name
|
||||||
@@ -29,7 +39,7 @@ export function UserMenu() {
|
|||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
handleClose()
|
handleClose()
|
||||||
logout()
|
logout()
|
||||||
navigate('/')
|
navigate('/auth/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const initials = getUserInitials(currentUser?.name ?? 'U')
|
const initials = getUserInitials(currentUser?.name ?? 'U')
|
||||||
@@ -46,15 +56,25 @@ export function UserMenu() {
|
|||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={!!anchorEl}
|
open={!!anchorEl}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
slotProps={{ paper: { sx: { width: 200, mt: 1 } } }}
|
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
>
|
>
|
||||||
|
{/* User info header */}
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<MenuItem disabled sx={{ opacity: '1 !important' }}>
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
|
<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}>
|
<MenuItem onClick={handleClose}>
|
||||||
<ListItemIcon><User size={16} /></ListItemIcon>
|
<ListItemIcon><User size={16} /></ListItemIcon>
|
||||||
Profil
|
Profil
|
||||||
@@ -63,7 +83,16 @@ export function UserMenu() {
|
|||||||
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{/* Demo role switcher */}
|
||||||
|
<Box sx={{ px: 1, py: 1 }}>
|
||||||
|
<DemoRoleSwitcher />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<MenuItem onClick={handleLogout}>
|
<MenuItem onClick={handleLogout}>
|
||||||
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
||||||
Abmelden
|
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 { useSessionStore } from '../stores/sessionStore'
|
||||||
import type { MockUser } from '../stores/sessionStore'
|
import type { MockUser } from '../stores/sessionStore'
|
||||||
import type { ItemResponse } from './types'
|
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 = {
|
export const authService = {
|
||||||
async getCurrentUser(): Promise<ItemResponse<MockUser | null>> {
|
async getCurrentUser(): Promise<ItemResponse<MockUser | null>> {
|
||||||
const data = useSessionStore.getState().currentUser
|
const data = useSessionStore.getState().currentUser
|
||||||
return { data }
|
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>> {
|
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',
|
id: 'user-001',
|
||||||
email,
|
email,
|
||||||
name: 'Admin User',
|
name: 'Admin User',
|
||||||
role: UserRole.ORGANIZATION_ADMIN,
|
role: UserRole.ORGANIZATION_ADMIN,
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
organizationName: 'Wincasa AG',
|
organizationName: 'Wincasa AG',
|
||||||
|
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
|
||||||
}
|
}
|
||||||
useSessionStore.getState().login(user)
|
useSessionStore.getState().login(user)
|
||||||
return { data: user }
|
return { data: user }
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout(): Promise<ItemResponse<void>> {
|
async logout(): Promise<ItemResponse<void>> {
|
||||||
useSessionStore.getState().logout()
|
useSessionStore.getState().logout()
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
|
|
||||||
async isAuthenticated(): Promise<ItemResponse<boolean>> {
|
async isAuthenticated(): Promise<ItemResponse<boolean>> {
|
||||||
const data = useSessionStore.getState().isAuthenticated
|
const data = useSessionStore.getState().isAuthenticated
|
||||||
return { data }
|
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 { create } from 'zustand'
|
||||||
import { UserRole } from '../domain/enums'
|
import { UserRole, WorkspaceType } from '../domain/enums'
|
||||||
|
|
||||||
export interface MockUser {
|
export interface MockUser {
|
||||||
id: string
|
id: string
|
||||||
@@ -8,15 +8,27 @@ export interface MockUser {
|
|||||||
role: UserRole
|
role: UserRole
|
||||||
organizationId: string
|
organizationId: string
|
||||||
organizationName: 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 {
|
interface SessionState {
|
||||||
currentUser: MockUser | null
|
currentUser: MockUser | null
|
||||||
activeOrganizationId: string | null
|
activeOrganizationId: string | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
sessionStatus: SessionStatus
|
||||||
login: (user: MockUser) => void
|
login: (user: MockUser) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
switchOrganization: (organizationId: string) => void
|
switchOrganization: (organizationId: string) => void
|
||||||
|
setSessionStatus: (status: SessionStatus) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockUser: MockUser = {
|
const mockUser: MockUser = {
|
||||||
@@ -26,13 +38,26 @@ const mockUser: MockUser = {
|
|||||||
role: UserRole.ORGANIZATION_ADMIN,
|
role: UserRole.ORGANIZATION_ADMIN,
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
organizationName: 'Wincasa AG',
|
organizationName: 'Wincasa AG',
|
||||||
|
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = create<SessionState>((set) => ({
|
export const useSessionStore = create<SessionState>((set) => ({
|
||||||
currentUser: mockUser,
|
currentUser: mockUser,
|
||||||
activeOrganizationId: mockUser.organizationId,
|
activeOrganizationId: mockUser.organizationId,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
login: (user) => set({ currentUser: user, activeOrganizationId: user.organizationId, isAuthenticated: true }),
|
sessionStatus: SessionStatus.AUTHENTICATED,
|
||||||
logout: () => set({ currentUser: null, activeOrganizationId: null, isAuthenticated: false }),
|
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 }),
|
switchOrganization: (organizationId) => set({ activeOrganizationId: organizationId }),
|
||||||
|
setSessionStatus: (status) => set({ sessionStatus: status }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user