diff --git a/CLAUDE.md b/CLAUDE.md index 10b246c0..a65e61d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,15 @@ When adding new frontend pages or components, use "ResolutionFlow" for any user- - Backend `get_refresh_token_payload` dependency extracts refresh token from Authorization header - Frontend Axios interceptor queues failed requests during refresh, retries after success - Auth store synced after silent refresh via `setTokens` action +- **RBAC & Permissions:** + - `is_super_admin` boolean on User model (migration 010) + - Role hierarchy: super_admin > team_admin > engineer > viewer + - `role` field values: 'engineer' | 'viewer' (no more 'admin') + - Team Admin = `role='engineer'` + `is_team_admin=True` + valid `team_id` + - Backend centralized: `backend/app/core/permissions.py` + - Frontend hook: `frontend/src/hooks/usePermissions.ts` + - Viewers CAN: browse trees, start sessions, rate steps + - Viewers CANNOT: create/edit trees, steps, tags, categories - **Session Scratchpad (Floating Overlay):** - Fixed-position overlay panel (420px wide, 55vh tall) on right edge - Floating button when collapsed, slide-in panel when expanded @@ -158,6 +167,7 @@ patherly/ │ │ ├── core/ │ │ │ ├── config.py # Settings (pydantic-settings) │ │ │ ├── database.py # Async SQLAlchemy +│ │ │ ├── permissions.py # Centralized RBAC (role checks, content guards) │ │ │ ├── security.py # JWT + password hashing │ │ │ ├── logging_config.py # Structured logging │ │ │ └── middleware.py # Request logging @@ -198,7 +208,7 @@ patherly/ │ │ │ ├── auth.ts │ │ │ ├── trees.ts │ │ │ └── sessions.ts -│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts) +│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts, usePermissions) │ │ ├── store/ │ │ │ ├── authStore.ts # Zustand auth state │ │ │ ├── themeStore.ts # Dark/light theme @@ -496,6 +506,17 @@ if settings.ALLOW_RAILWAY_ORIGINS: ``` When using `allow_origin_regex` for wildcard patterns, also include `allow_origins` for explicit custom domains. The regex alone won't match custom domains like `resolutionflow.com`. +### RBAC Permission Checks + +- Backend auth deps: `get_current_user` (any logged-in), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only) +- Backend: `is_super_admin` replaces all `role == "admin"` checks. Never use `role == "admin"`. +- Frontend: use `usePermissions()` hook for all role/permission checks +- `TreeListItem` includes `team_id` for frontend permission checks (`author_id` and `team_id` are nullable) + +### Alembic Migrations: Test Data State Before Writing WHERE Clauses + +Migration 010 had `WHERE role = 'admin'` but the only user already had `role = 'engineer'` (changed by earlier work), so the UPDATE matched zero rows. Always verify actual data values before writing conditional migrations, or use broader conditions. + ### findNode Requires Tree Structure Parameter ```tsx @@ -792,6 +813,7 @@ Position overlay at `right-2` (not `right-0`) so it sits inside the page scrollb 1. Create test database: `docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"` 2. Install dev deps: `pip install -r requirements-dev.txt` 3. Ensure pytest-asyncio version: `pip install pytest-asyncio==0.24.0` +4. If `unrecognized arguments: --cov` error: run with `--override-ini="addopts="` or install `pytest-cov` ### API 500 errors diff --git a/backend/alembic/versions/010_add_is_super_admin.py b/backend/alembic/versions/010_add_is_super_admin.py new file mode 100644 index 00000000..27fb2a2f --- /dev/null +++ b/backend/alembic/versions/010_add_is_super_admin.py @@ -0,0 +1,33 @@ +"""add is_super_admin to users + +Revision ID: 010 +Revises: 009 +Create Date: 2026-02-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '010' +down_revision: Union[str, None] = '009' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add is_super_admin flag + op.add_column('users', + sa.Column('is_super_admin', sa.Boolean(), nullable=False, server_default=sa.text('false')) + ) + # Migrate existing admin users: promote to super admin AND normalize role + op.execute("UPDATE users SET is_super_admin = TRUE, role = 'engineer' WHERE role = 'admin'") + + +def downgrade() -> None: + # Restore admin role for super admins before dropping column + op.execute("UPDATE users SET role = 'admin' WHERE is_super_admin = TRUE") + op.drop_column('users', 'is_super_admin') diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index ee738c77..016fd006 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -75,11 +75,11 @@ async def get_current_active_user( async def require_admin( current_user: Annotated[User, Depends(get_current_active_user)] ) -> User: - """Require admin role.""" - if current_user.role != "admin": + """Require super admin access.""" + if not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin access required" + detail="Super admin access required" ) return current_user @@ -87,8 +87,10 @@ async def require_admin( async def require_engineer_or_admin( current_user: Annotated[User, Depends(get_current_active_user)] ) -> User: - """Require engineer or admin role.""" - if current_user.role not in ("admin", "engineer"): + """Require engineer, team admin, or super admin role (blocks viewers).""" + if current_user.is_super_admin: + return current_user + if current_user.role not in ("engineer",): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Engineer or admin access required" diff --git a/backend/app/api/endpoints/categories.py b/backend/app/api/endpoints/categories.py index c63d2db5..b9ef3a6b 100644 --- a/backend/app/api/endpoints/categories.py +++ b/backend/app/api/endpoints/categories.py @@ -25,7 +25,7 @@ def slugify(name: str) -> str: def can_manage_category(user: User, category: TreeCategory) -> bool: """Check if user can manage (edit/delete) a category.""" # Global admins can manage any category - if user.role == "admin": + if user.is_super_admin: return True # Team admins can manage their team's categories if user.is_team_admin and category.team_id == user.team_id: @@ -36,7 +36,7 @@ def can_manage_category(user: User, category: TreeCategory) -> bool: def can_create_category(user: User, team_id: Optional[UUID]) -> bool: """Check if user can create a category for the given team.""" # Global admins can create global categories (team_id=None) or any team's categories - if user.role == "admin": + if user.is_super_admin: return True # Team admins can only create categories for their own team if user.is_team_admin and team_id == user.team_id: @@ -123,7 +123,7 @@ async def get_category( ) # Check access: global categories visible to all, team categories only to team members - if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin": + if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this category" diff --git a/backend/app/api/endpoints/folders.py b/backend/app/api/endpoints/folders.py index f2358eb8..918bc543 100644 --- a/backend/app/api/endpoints/folders.py +++ b/backend/app/api/endpoints/folders.py @@ -78,7 +78,7 @@ def can_access_tree(user: User, tree: Tree) -> bool: return True if tree.team_id == user.team_id and user.team_id is not None: return True - if user.role == "admin": + if user.is_super_admin: return True return False diff --git a/backend/app/api/endpoints/step_categories.py b/backend/app/api/endpoints/step_categories.py index c21ab917..6c06a8c6 100644 --- a/backend/app/api/endpoints/step_categories.py +++ b/backend/app/api/endpoints/step_categories.py @@ -22,7 +22,7 @@ router = APIRouter(prefix="/step-categories", tags=["step-categories"]) def can_manage_step_category(user: User, category: StepCategory) -> bool: """Check if user can manage (edit/delete) a step category.""" # Global admins can manage any category - if user.role == "admin": + if user.is_super_admin: return True # Team admins can manage their team's categories if user.is_team_admin and category.team_id == user.team_id: @@ -33,7 +33,7 @@ def can_manage_step_category(user: User, category: StepCategory) -> bool: def can_create_step_category(user: User, team_id: Optional[UUID]) -> bool: """Check if user can create a step category for the given team.""" # Global admins can create global categories (team_id=None) or any team's categories - if user.role == "admin": + if user.is_super_admin: return True # Team admins can only create categories for their own team if user.is_team_admin and team_id == user.team_id: @@ -113,7 +113,7 @@ async def get_step_category( ) # Check access: global categories visible to all, team categories only to team members - if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin": + if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this step category" diff --git a/backend/app/api/endpoints/steps.py b/backend/app/api/endpoints/steps.py index 48291639..ce1d67e0 100644 --- a/backend/app/api/endpoints/steps.py +++ b/backend/app/api/endpoints/steps.py @@ -7,7 +7,7 @@ from sqlalchemy import select, or_, and_, func, desc, Integer, case from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.api.deps import get_current_user +from app.api.deps import get_current_user, require_engineer_or_admin from app.models.user import User from app.models.step_library import StepLibrary, StepRating from app.models.step_category import StepCategory @@ -33,14 +33,16 @@ def can_view_step(user: User, step: StepLibrary) -> bool: if step.visibility == 'private': return step.created_by == user.id if step.visibility == 'team': - return step.team_id == user.team_id or user.role == 'admin' + return step.team_id == user.team_id or user.is_super_admin return False def can_edit_step(user: User, step: StepLibrary) -> bool: """Check if user can edit/delete a step.""" - if user.role == 'admin': + if user.is_super_admin: return True + if user.role == 'viewer': + return False return step.created_by == user.id @@ -300,9 +302,9 @@ async def get_step( async def create_step( step_data: StepLibraryCreate, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_engineer_or_admin) ): - """Create a new step.""" + """Create a new step (engineers and admins only, not viewers).""" # Validate category if provided if step_data.category_id: cat_result = await db.execute( @@ -316,7 +318,7 @@ async def create_step( # Team validation: can only set team_id to own team team_id = step_data.team_id - if team_id and team_id != current_user.team_id and current_user.role != 'admin': + if team_id and team_id != current_user.team_id and not current_user.is_super_admin: raise HTTPException(status_code=403, detail="Cannot create step for another team") step = StepLibrary( diff --git a/backend/app/api/endpoints/tags.py b/backend/app/api/endpoints/tags.py index 27492372..9799f8d7 100644 --- a/backend/app/api/endpoints/tags.py +++ b/backend/app/api/endpoints/tags.py @@ -19,13 +19,15 @@ def can_manage_tree_tags(user: User, tree: Tree) -> bool: """Check if user can manage tags on a tree. Allowed: - - Tree author - - Global admins + - Tree author (engineer+) + - Super admins - Team admins for their team's trees """ - if user.id == tree.author_id: + if user.is_super_admin: return True - if user.role == "admin": + if user.role == "viewer": + return False + if user.id == tree.author_id: return True if user.is_team_admin and tree.team_id == user.team_id: return True @@ -35,12 +37,15 @@ def can_manage_tree_tags(user: User, tree: Tree) -> bool: def can_create_tag(user: User, team_id: Optional[UUID]) -> bool: """Check if user can create a tag for the given scope. - - Global admins can create global tags (team_id=None) - - Team admins and global admins can create team-specific tags - - Regular users can create team tags for their own team + - Super admins can create global tags (team_id=None) + - Team admins and super admins can create team-specific tags + - Engineers can create team tags for their own team + - Viewers cannot create tags """ - if user.role == "admin": + if user.is_super_admin: return True + if user.role == "viewer": + return False # For team-specific tags, user must belong to that team if team_id is not None and team_id == user.team_id: return True @@ -133,7 +138,7 @@ async def get_tag( ) # Check access: global tags visible to all, team tags only to team members - if tag.team_id and tag.team_id != current_user.team_id and current_user.role != "admin": + if tag.team_id and tag.team_id != current_user.team_id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tag" @@ -231,7 +236,7 @@ async def add_tags_to_tree( # Try to find existing tag # Determine scope: use tree's team, or global for admin-owned trees - tag_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + tag_team_id = tree.team_id or (current_user.team_id if not current_user.is_super_admin else None) tag_query = select(TreeTag).where( TreeTag.slug == slug, @@ -361,7 +366,7 @@ async def replace_tree_tags( tree.tags.clear() # Add new tags - tag_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + tag_team_id = tree.team_id or (current_user.team_id if not current_user.is_super_admin else None) for tag_name in tag_data.tags: slug = TreeTag.slugify(tag_name) @@ -428,7 +433,7 @@ async def get_tree_tags( if not tree.is_public: if tree.author_id != current_user.id: if tree.team_id != current_user.team_id: - if current_user.role != "admin": + if not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree" diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index d9e20691..d1f2c771 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -53,6 +53,7 @@ def build_tree_response(tree: Tree) -> TreeListResponse: category_info=category_info, tags=tree.tag_names, author_id=tree.author_id, + team_id=tree.team_id, is_active=tree.is_active, is_public=tree.is_public, is_default=tree.is_default, @@ -250,7 +251,7 @@ async def get_tree( tree.is_public or tree.author_id == current_user.id or (tree.team_id == current_user.team_id and current_user.team_id is not None) or - current_user.role == "admin" + current_user.is_super_admin ) if not tree.is_active or not can_access: raise HTTPException( @@ -274,7 +275,7 @@ async def create_tree( - tags: List of tag names to assign (creates new tags if needed) """ # Only admins can create default/system trees - is_default = tree_data.is_default and current_user.role == "admin" + is_default = tree_data.is_default and current_user.is_super_admin # Verify category exists if provided if tree_data.category_id: @@ -288,7 +289,7 @@ async def create_tree( detail="Category not found" ) # Check category access - if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin": + if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this category" @@ -310,7 +311,7 @@ async def create_tree( # Handle tags if tree_data.tags: - tree_team_id = new_tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + tree_team_id = new_tree.team_id or (current_user.team_id if not current_user.is_super_admin else None) # Collect tags to add tags_to_add = [] @@ -401,7 +402,7 @@ async def update_tree( # Check if user can edit: must be author, team admin for team trees, or global admin can_edit = ( tree.author_id == current_user.id or - current_user.role == "admin" or + current_user.is_super_admin or (current_user.is_team_admin and tree.team_id == current_user.team_id) ) if not can_edit: @@ -425,7 +426,7 @@ async def update_tree( status_code=status.HTTP_404_NOT_FOUND, detail="Category not found" ) - if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin": + if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this category" @@ -455,7 +456,7 @@ async def update_tree( ) # Add new tags - tree_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + tree_team_id = tree.team_id or (current_user.team_id if not current_user.is_super_admin else None) added_tag_ids = set() for tag_name in tags_data: diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py new file mode 100644 index 00000000..2276f02a --- /dev/null +++ b/backend/app/core/permissions.py @@ -0,0 +1,94 @@ +""" +Centralized permission checks for ResolutionFlow. + +Role hierarchy: super_admin > team_admin > engineer > viewer + +- super_admin: is_super_admin=True, full system access +- team_admin: is_team_admin=True + valid team_id, manage team resources +- engineer: role='engineer' (default), CRUD own trees/steps +- viewer: role='viewer', read-only (can browse, run sessions, rate steps) +""" +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.models.user import User + from app.models.tree import Tree + from app.models.step_library import StepLibrary + from app.models.category import TreeCategory + +ROLE_HIERARCHY = { + "super_admin": 4, + "team_admin": 3, + "engineer": 2, + "viewer": 1, +} + + +def get_effective_role(user: User) -> str: + """Get the effective role considering is_super_admin and is_team_admin flags.""" + if user.is_super_admin: + return "super_admin" + if user.is_team_admin and user.team_id is not None: + return "team_admin" + return user.role # "engineer" or "viewer" + + +def has_minimum_role(user: User, minimum_role: str) -> bool: + """Check if user has at least the specified role level.""" + effective = get_effective_role(user) + return ROLE_HIERARCHY.get(effective, 0) >= ROLE_HIERARCHY.get(minimum_role, 0) + + +def can_create_content(user: User) -> bool: + """Can the user create trees, steps, or other content? Viewers cannot.""" + return has_minimum_role(user, "engineer") + + +def can_edit_tree(user: User, tree: Tree) -> bool: + """Can the user edit this specific tree?""" + if user.is_super_admin: + return True + if not can_create_content(user): + return False + if tree.author_id == user.id: + return True + if user.is_team_admin and tree.team_id == user.team_id and user.team_id is not None: + return True + return False + + +def can_delete_tree(user: User, tree: Tree) -> bool: + """Can the user delete this tree? Super admin only.""" + return user.is_super_admin + + +def can_edit_step(user: User, step: StepLibrary) -> bool: + """Can the user edit/delete this step?""" + if user.is_super_admin: + return True + if not can_create_content(user): + return False + return step.created_by == user.id + + +def can_manage_category(user: User, category: TreeCategory) -> bool: + """Can the user edit/delete this category?""" + if user.is_super_admin: + return True + if user.is_team_admin and category.team_id == user.team_id and user.team_id is not None: + return True + return False + + +def can_manage_tree_tags(user: User, tree: Tree) -> bool: + """Can the user add/remove tags on this tree?""" + if user.is_super_admin: + return True + if not can_create_content(user): + return False + if tree.author_id == user.id: + return True + if user.is_team_admin and tree.team_id == user.team_id and user.team_id is not None: + return True + return False diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f824470b..04b92c93 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -25,6 +25,7 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer") + is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) team_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), @@ -50,10 +51,10 @@ class User(Base): @property def is_admin(self) -> bool: - """Returns True if user is a global (ResolutionFlow) admin.""" - return self.role == "admin" + """Returns True if user is a super admin (system-wide access).""" + return self.is_super_admin @property def can_manage_team(self) -> bool: - """Returns True if user can manage their team (team admin or global admin).""" - return self.is_admin or (self.is_team_admin and self.team_id is not None) + """Returns True if user can manage their team (team admin or super admin).""" + return self.is_super_admin or (self.is_team_admin and self.team_id is not None) diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index de9b3b25..8810f0d5 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -69,6 +69,7 @@ class TreeListResponse(BaseModel): category_info: Optional[CategoryInfo] = None tags: list[str] = [] # List of tag names author_id: Optional[UUID] = None + team_id: Optional[UUID] = None is_active: bool is_public: bool is_default: bool diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 888c73b1..02c9aff1 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -11,7 +11,7 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str = Field(..., min_length=10, description="Password must be at least 10 characters") - role: str = Field(default="engineer", description="User role: admin, engineer, or viewer") + role: str = Field(default="engineer", description="User role: engineer or viewer") invite_code: Optional[str] = Field(None, description="Invite code for registration (required when invite system is enabled)") @@ -28,6 +28,7 @@ class UserLogin(BaseModel): class UserResponse(UserBase): id: UUID role: str + is_super_admin: bool = False is_team_admin: bool = False team_id: Optional[UUID] = None created_at: datetime diff --git a/docs/RBAC-IMPLEMENTATION-QUESTIONS.md b/docs/RBAC-IMPLEMENTATION-QUESTIONS.md new file mode 100644 index 00000000..88b48d76 --- /dev/null +++ b/docs/RBAC-IMPLEMENTATION-QUESTIONS.md @@ -0,0 +1,91 @@ +# RBAC Implementation - Flagged Questions & Context + +## Purpose +This document supplements Claude Code's RBAC implementation plan with questions and considerations for ResolutionFlow's multi-tenant SaaS architecture. + +--- + +## Multi-Tenant Model Overview + +### Account Tiers +- **Free/Individual** — Single user, access to public trees only +- **Team Package** — Team owner + invited members, private team trees + public trees + +### Content Visibility +- **Public Trees** — ResolutionFlow official content, visible to all authenticated users +- **Team Trees** — Created by team members, visible only to that team + +### Role Hierarchy + +| Role | Scope | Capabilities | +|------|-------|--------------| +| **Super Admin** | System-wide | Manage public trees, all teams, all users | +| **Team Owner** | Their team | Everything Team Admin can do + billing management | +| **Team Admin** | Their team | Invite/remove members, assign roles, manage team trees | +| **Engineer** | Their team | Create/edit team trees & steps, fork public trees | +| **Viewer** | Their team | Read-only access to team + public trees, run sessions | + +### Key Architectural Decisions +- Database should support multi-team membership (junction table), but UI enforces single team for now +- Team Owner is functionally a Team Admin with billing powers (no separate role needed initially) + +--- + +## Flagged Questions for Implementation + +### 1. Step Library Permissions (Phase 2.5) +- Can viewers rate/review steps, or is that engineer+ only? +- How do ratings work across teams — are they global or team-scoped? + +### 2. Session Permissions +- Can viewers *create* new troubleshooting sessions, or only view past session history? +- Clarify the intended behavior before implementing. + +### 3. Personal Tree Branching (Phase 2.5) +- When a user forks a public tree into their team's space, who owns it? +- Does the original author get attribution, or is it a clean copy? + +### 4. Team Content Isolation +- The current plan references `team_id` but doesn't explicitly enforce that team trees are invisible to other teams. +- Ensure `build_tree_access_filter()` and `build_visibility_filter()` account for: + - User's team membership + - Public vs team-private trees + - Cross-team isolation + +### 5. Super Admin Role +- The plan has "admin" but doesn't distinguish between "ResolutionFlow super admin" (system-wide) and "Team Admin" (team-scoped). +- Consider: Should the database have an `is_super_admin` flag, or is this handled by a specific team/user ID? + +### 6. Future Multi-Team Membership +- The database should support users belonging to multiple teams (junction table) even if the UI enforces single-team for now. +- Confirm the schema supports this or flag what migration would be needed later. + +### 7. Subscription Lapse Handling (Low Priority - Not MVP) +- What happens to team trees if a team subscription expires? +- Options to consider: + - Team downgraded to viewer-only (can see but not edit) + - Grace period before content becomes inaccessible + - Export option before lockout +- Not needed for MVP, but note where this logic would hook in. + +--- + +## Implementation Checklist + +Before starting RBAC implementation, confirm answers to: + +- [ ] Session permissions: Can viewers create sessions or only view history? +- [ ] Step ratings: Viewer participation yes/no? +- [ ] Super admin flag: New DB field or handled differently? +- [ ] Multi-team schema: Current schema supports this or needs migration? + +--- + +## Related Documentation +- Claude Code's original RBAC plan (see project knowledge) +- Phase 2.5 roadmap (Step Library, Personal Branching) +- CURRENT-STATE.md + +--- + +*Last updated: February 2025* diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index b520d733..8b83b1bd 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,5 +1,6 @@ import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom' import { useAuthStore } from '@/store/authStore' +import { usePermissions } from '@/hooks/usePermissions' import { ThemeToggle } from '@/components/common/ThemeToggle' import { BrandLogo } from '@/components/common/BrandLogo' import { BrandWordmark } from '@/components/common/BrandWordmark' @@ -9,6 +10,7 @@ export function AppLayout() { const location = useLocation() const navigate = useNavigate() const { user, logout } = useAuthStore() + const { effectiveRole } = usePermissions() const handleLogout = async () => { await logout() @@ -53,6 +55,20 @@ export function AppLayout() { {user?.name || user?.email} + {effectiveRole && effectiveRole !== 'engineer' && ( + + {effectiveRole === 'super_admin' ? 'Super Admin' : + effectiveRole === 'team_admin' ? 'Team Admin' : + 'Viewer'} + + )}