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
+132
View File
@@ -0,0 +1,132 @@
import type { MockUser } from '../stores/sessionStore'
import { UserRole, WorkspaceType, ResultType } from '../domain/enums'
// ── Permission Strings ────────────────────────────────────────────────────────
export const Permission = {
SUPPLY_VIEW: 'supply:view',
SUPPLY_EDIT: 'supply:edit',
DEMAND_VIEW: 'demand:view',
DEMAND_EDIT: 'demand:edit',
DEMAND_REQUEST_CONTACT: 'demand:request_contact',
OPS_VIEW: 'ops:view',
OPS_REVIEW: 'ops:review',
OPS_APPROVE: 'ops:approve',
FUTURE_SIGNAL_VIEW: 'future_signal:view',
FUTURE_SIGNAL_REVIEW: 'future_signal:review',
CONTACT_RELEASE_REQUEST: 'contact_release:request',
CONTACT_RELEASE_APPROVE: 'contact_release:approve',
} as const
export type Permission = typeof Permission[keyof typeof Permission]
// ── Role → Permission Matrix ──────────────────────────────────────────────────
const ALL_PERMISSIONS = Object.values(Permission)
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.SUPER_ADMIN]: ALL_PERMISSIONS,
[UserRole.ORGANIZATION_ADMIN]: ALL_PERMISSIONS,
[UserRole.PROPERTY_MANAGER]: [
Permission.SUPPLY_VIEW,
Permission.SUPPLY_EDIT,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
Permission.CONTACT_RELEASE_APPROVE,
],
[UserRole.REVIEWER]: [
Permission.OPS_VIEW,
Permission.OPS_REVIEW,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
],
[UserRole.OWNER_VIEWER]: [
Permission.SUPPLY_VIEW,
Permission.CONTACT_RELEASE_APPROVE,
],
[UserRole.DEMAND_USER]: [
Permission.DEMAND_VIEW,
Permission.DEMAND_EDIT,
Permission.DEMAND_REQUEST_CONTACT,
Permission.CONTACT_RELEASE_REQUEST,
],
}
// ── Role → Workspace Access ───────────────────────────────────────────────────
const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
[WorkspaceType.SUPPLY]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.PROPERTY_MANAGER,
UserRole.OWNER_VIEWER,
],
[WorkspaceType.DEMAND]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.DEMAND_USER,
],
[WorkspaceType.OPERATIONS]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.REVIEWER,
],
}
// ── Core Functions ────────────────────────────────────────────────────────────
export function getPermissions(user: MockUser): Permission[] {
return ROLE_PERMISSIONS[user.role] ?? []
}
export function hasPermission(user: MockUser, permission: Permission): boolean {
return getPermissions(user).includes(permission)
}
export function canAccessWorkspace(user: MockUser, workspace: WorkspaceType): boolean {
return WORKSPACE_ROLES[workspace].includes(user.role)
}
export function getAccessibleWorkspaces(role: UserRole): WorkspaceType[] {
return Object.entries(WORKSPACE_ROLES)
.filter(([, roles]) => roles.includes(role))
.map(([ws]) => ws as WorkspaceType)
}
// ── Domain Permission Functions ───────────────────────────────────────────────
export function canViewProperty(
user: MockUser,
property: { organizationId: string; resultType: ResultType },
): boolean {
if (user.role === UserRole.SUPER_ADMIN) return true
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
return property.organizationId === user.organizationId
}
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
}
return false
}
export function canViewMatch(
user: MockUser,
match: { organizationId?: string },
): boolean {
if (user.role === UserRole.SUPER_ADMIN || user.role === UserRole.ORGANIZATION_ADMIN) return true
if (user.role === UserRole.PROPERTY_MANAGER || user.role === UserRole.REVIEWER) return true
if (user.role === UserRole.DEMAND_USER) return match.organizationId === user.organizationId
return false // OWNER_VIEWER: only released matches — handled at component level
}
export function canReviewFutureSignal(user: MockUser): boolean {
return hasPermission(user, Permission.FUTURE_SIGNAL_REVIEW)
}
export function canApproveContactRelease(user: MockUser): boolean {
return hasPermission(user, Permission.CONTACT_RELEASE_APPROVE)
}
export function canRequestContactRelease(user: MockUser): boolean {
return hasPermission(user, Permission.CONTACT_RELEASE_REQUEST)
}