feat: F002 authentication & session layer

Add permission matrix, ProtectedRoute, RoleGuard, PermissionGate,
DemoRoleSwitcher, OrganizationSwitcher, AccessDenied, SessionExpired,
and institutional LoginScreen. Wire workspace-level route protection
into App.tsx; sidebar filters tabs by allowedWorkspaces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 12:01:46 +02:00
parent 9a2476c58a
commit 7bed2fec86
15 changed files with 852 additions and 29 deletions
+103 -2
View File
@@ -1,31 +1,132 @@
import { useSessionStore } from '../stores/sessionStore'
import type { MockUser } from '../stores/sessionStore'
import type { ItemResponse } from './types'
import { UserRole } from '../domain/enums'
import { UserRole, WorkspaceType } from '../domain/enums'
import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions'
import type { Permission } from '../lib/permissions'
// Mock organizations for org switching
const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [
{ id: 'org-wincasa', name: 'Wincasa AG' },
{ id: 'org-mobimo', name: 'Mobimo Management AG' },
{ id: 'org-ubs', name: 'UBS Asset Management RE' },
]
// Demo user presets per role
const DEMO_USERS: Record<UserRole, MockUser> = {
[UserRole.SUPER_ADMIN]: {
id: 'user-super',
email: 'super@ideal-sharing.ch',
name: 'Super Admin',
role: UserRole.SUPER_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
},
[UserRole.ORGANIZATION_ADMIN]: {
id: 'user-001',
email: 'admin@ideal-sharing.ch',
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
},
[UserRole.PROPERTY_MANAGER]: {
id: 'user-pm',
email: 'pm@ideal-sharing.ch',
name: 'Property Manager',
role: UserRole.PROPERTY_MANAGER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.PROPERTY_MANAGER),
},
[UserRole.REVIEWER]: {
id: 'user-rev',
email: 'reviewer@ideal-sharing.ch',
name: 'Reviewer',
role: UserRole.REVIEWER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.REVIEWER),
},
[UserRole.OWNER_VIEWER]: {
id: 'user-ov',
email: 'owner@ideal-sharing.ch',
name: 'Owner Viewer',
role: UserRole.OWNER_VIEWER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.OWNER_VIEWER),
},
[UserRole.DEMAND_USER]: {
id: 'user-dem',
email: 'demand@ideal-sharing.ch',
name: 'Demand User',
role: UserRole.DEMAND_USER,
organizationId: 'org-mobimo',
organizationName: 'Mobimo Management AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.DEMAND_USER),
},
}
export const authService = {
async getCurrentUser(): Promise<ItemResponse<MockUser | null>> {
const data = useSessionStore.getState().currentUser
return { data }
},
async getCurrentOrganization(): Promise<ItemResponse<{ id: string; name: string } | null>> {
const { activeOrganizationId } = useSessionStore.getState()
const org = MOCK_ORGANIZATIONS.find((o) => o.id === activeOrganizationId) ?? null
return { data: org }
},
async login(email: string, _password: string): Promise<ItemResponse<MockUser>> {
const user: MockUser = {
const existing = Object.values(DEMO_USERS).find((u) => u.email === email)
const user: MockUser = existing ?? {
id: 'user-001',
email,
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
}
useSessionStore.getState().login(user)
return { data: user }
},
async logout(): Promise<ItemResponse<void>> {
useSessionStore.getState().logout()
return { data: undefined }
},
async isAuthenticated(): Promise<ItemResponse<boolean>> {
const data = useSessionStore.getState().isAuthenticated
return { data }
},
async switchDemoRole(role: UserRole): Promise<ItemResponse<MockUser>> {
const user = DEMO_USERS[role]
useSessionStore.getState().login(user)
return { data: user }
},
async switchOrganization(organizationId: string): Promise<ItemResponse<void>> {
const org = MOCK_ORGANIZATIONS.find((o) => o.id === organizationId)
if (org) {
const state = useSessionStore.getState()
if (state.currentUser) {
state.login({ ...state.currentUser, organizationId: org.id, organizationName: org.name })
} else {
state.switchOrganization(organizationId)
}
}
return { data: undefined }
},
async getPermissions(user: MockUser): Promise<ItemResponse<Permission[]>> {
return { data: getPermissions(user) }
},
}