From 7bed2fec868a527012e4e517af0785484e29dfae Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 12:01:46 +0200 Subject: [PATCH] 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 --- src/App.tsx | 52 +++-- src/components/auth/AccessDenied.tsx | 75 +++++++ src/components/auth/DemoRoleSwitcher.tsx | 55 +++++ src/components/auth/OrganizationSwitcher.tsx | 40 ++++ src/components/auth/PermissionGate.tsx | 19 ++ src/components/auth/ProtectedRoute.tsx | 38 ++++ src/components/auth/RoleGuard.tsx | 20 ++ src/components/auth/SessionExpired.tsx | 60 ++++++ src/components/auth/index.ts | 7 + src/components/layout/AppShell.tsx | 7 +- src/components/layout/UserMenu.tsx | 39 +++- src/lib/permissions.ts | 132 ++++++++++++ src/pages/auth/LoginScreen.tsx | 201 +++++++++++++++++++ src/services/authService.ts | 105 +++++++++- src/stores/sessionStore.ts | 31 ++- 15 files changed, 852 insertions(+), 29 deletions(-) create mode 100644 src/components/auth/AccessDenied.tsx create mode 100644 src/components/auth/DemoRoleSwitcher.tsx create mode 100644 src/components/auth/OrganizationSwitcher.tsx create mode 100644 src/components/auth/PermissionGate.tsx create mode 100644 src/components/auth/ProtectedRoute.tsx create mode 100644 src/components/auth/RoleGuard.tsx create mode 100644 src/components/auth/SessionExpired.tsx create mode 100644 src/components/auth/index.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/pages/auth/LoginScreen.tsx diff --git a/src/App.tsx b/src/App.tsx index c752a1f..03a137e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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,27 +28,39 @@ function App() { }> - }> - } /> + {/* Public */} + } /> - {/* Supply Workspace */} - } /> - } /> - } /> - } /> - } /> + {/* Protected: auth check only */} + }> + }> + } /> - {/* Demand Workspace */} - } /> - } /> - } /> - } /> + {/* Supply Workspace */} + }> + } /> + } /> + } /> + } /> + } /> + - {/* Operations Workspace */} - } /> - } /> - } /> - } /> + {/* Demand Workspace */} + }> + } /> + } /> + } /> + } /> + + + {/* Operations Workspace */} + }> + } /> + } /> + } /> + } /> + + } /> diff --git a/src/components/auth/AccessDenied.tsx b/src/components/auth/AccessDenied.tsx new file mode 100644 index 0000000..e15dfc4 --- /dev/null +++ b/src/components/auth/AccessDenied.tsx @@ -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 +} + +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 ( + + + + + + + + {title} + + + {message} + + + + + + ) +} diff --git a/src/components/auth/DemoRoleSwitcher.tsx b/src/components/auth/DemoRoleSwitcher.tsx new file mode 100644 index 0000000..bd8ddd6 --- /dev/null +++ b/src/components/auth/DemoRoleSwitcher.tsx @@ -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.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 ( + + + Demo-Modus + + + {Object.values(UserRole).map((role) => { + const active = currentUser?.role === role + return ( + 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)' }, + }} + /> + ) + })} + + + ) +} diff --git a/src/components/auth/OrganizationSwitcher.tsx b/src/components/auth/OrganizationSwitcher.tsx new file mode 100644 index 0000000..56e0193 --- /dev/null +++ b/src/components/auth/OrganizationSwitcher.tsx @@ -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) { + await authService.switchOrganization(e.target.value) + } + + return ( + + + Organisation + + + + ) +} diff --git a/src/components/auth/PermissionGate.tsx b/src/components/auth/PermissionGate.tsx new file mode 100644 index 0000000..edd8ccb --- /dev/null +++ b/src/components/auth/PermissionGate.tsx @@ -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} +} diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..a8007f6 --- /dev/null +++ b/src/components/auth/ProtectedRoute.tsx @@ -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 + } + + if (sessionStatus === SessionStatus.EXPIRED) { + return + } + + if (workspace && currentUser && !canAccessWorkspace(currentUser, workspace)) { + const workspaceLabel: Record = { + SUPPLY: 'Supply', + DEMAND: 'Demand', + OPERATIONS: 'Operations', + } + return ( + + ) + } + + return +} diff --git a/src/components/auth/RoleGuard.tsx b/src/components/auth/RoleGuard.tsx new file mode 100644 index 0000000..577c360 --- /dev/null +++ b/src/components/auth/RoleGuard.tsx @@ -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 ?? } + } + + return <>{children} +} diff --git a/src/components/auth/SessionExpired.tsx b/src/components/auth/SessionExpired.tsx new file mode 100644 index 0000000..0ede7c4 --- /dev/null +++ b/src/components/auth/SessionExpired.tsx @@ -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 ( + + + + + + + + Sitzung abgelaufen + + + Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an. + + + + + + ) +} diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 0000000..9588616 --- /dev/null +++ b/src/components/auth/index.ts @@ -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' diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 08c53ca..fe5f741 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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 ( - {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 ( = { + 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 && ( - + {currentUser.name} - + + {ROLE_LABELS[currentUser.role] ?? currentUser.role} + + + {currentUser.organizationName} + + )} + + + Profil @@ -63,7 +83,16 @@ export function UserMenu() { Einstellungen + + + {/* Demo role switcher */} + + + + + + Abmelden diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..66ad8f8 --- /dev/null +++ b/src/lib/permissions.ts @@ -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.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.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) +} diff --git a/src/pages/auth/LoginScreen.tsx b/src/pages/auth/LoginScreen.tsx new file mode 100644 index 0000000..1b58155 --- /dev/null +++ b/src/pages/auth/LoginScreen.tsx @@ -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(null) + + if (isAuthenticated) { + return + } + + 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 ( + + + {/* Branding */} + + + + + + + Property Match + + + Decision Intelligence + + + + + {/* Login card */} + + + Anmelden + + Melden Sie sich mit Ihren Zugangsdaten an. + + + + setEmail(e.target.value)} + autoComplete="email" + required + /> + setPassword(e.target.value)} + autoComplete="current-password" + helperText="Im Demo-Modus wird jedes Passwort akzeptiert." + /> + + {error && ( + + {error} + + )} + + + + + + + {/* Demo access */} + + + + + + Demo-Zugänge + + + + + + + {DEMO_ROLES.map(({ role, label, description }) => ( + 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)' }, + }} + > + + + {label} + + + {description} + + + + + ))} + + + + + + ) +} diff --git a/src/services/authService.ts b/src/services/authService.ts index cf48305..986b0d2 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -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.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> { const data = useSessionStore.getState().currentUser return { data } }, + + async getCurrentOrganization(): Promise> { + const { activeOrganizationId } = useSessionStore.getState() + const org = MOCK_ORGANIZATIONS.find((o) => o.id === activeOrganizationId) ?? null + return { data: org } + }, + async login(email: string, _password: string): Promise> { - 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> { useSessionStore.getState().logout() return { data: undefined } }, + async isAuthenticated(): Promise> { const data = useSessionStore.getState().isAuthenticated return { data } }, + + async switchDemoRole(role: UserRole): Promise> { + const user = DEMO_USERS[role] + useSessionStore.getState().login(user) + return { data: user } + }, + + async switchOrganization(organizationId: string): Promise> { + 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> { + return { data: getPermissions(user) } + }, } diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 7d93d93..4cb9a32 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -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((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 }), }))