feat: implement RBAC permissions system
Add role-based access control with hierarchy: super_admin > team_admin > engineer > viewer. Adds is_super_admin boolean to User model (migration 010), centralized backend permissions module, frontend usePermissions hook, and UI enforcement (conditional Create/Edit buttons, editor redirect for viewers, role badge in header). All endpoint admin checks updated from role=="admin" to is_super_admin. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
24
CLAUDE.md
24
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
|
- Backend `get_refresh_token_payload` dependency extracts refresh token from Authorization header
|
||||||
- Frontend Axios interceptor queues failed requests during refresh, retries after success
|
- Frontend Axios interceptor queues failed requests during refresh, retries after success
|
||||||
- Auth store synced after silent refresh via `setTokens` action
|
- 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):**
|
- **Session Scratchpad (Floating Overlay):**
|
||||||
- Fixed-position overlay panel (420px wide, 55vh tall) on right edge
|
- Fixed-position overlay panel (420px wide, 55vh tall) on right edge
|
||||||
- Floating button when collapsed, slide-in panel when expanded
|
- Floating button when collapsed, slide-in panel when expanded
|
||||||
@@ -158,6 +167,7 @@ patherly/
|
|||||||
│ │ ├── core/
|
│ │ ├── core/
|
||||||
│ │ │ ├── config.py # Settings (pydantic-settings)
|
│ │ │ ├── config.py # Settings (pydantic-settings)
|
||||||
│ │ │ ├── database.py # Async SQLAlchemy
|
│ │ │ ├── database.py # Async SQLAlchemy
|
||||||
|
│ │ │ ├── permissions.py # Centralized RBAC (role checks, content guards)
|
||||||
│ │ │ ├── security.py # JWT + password hashing
|
│ │ │ ├── security.py # JWT + password hashing
|
||||||
│ │ │ ├── logging_config.py # Structured logging
|
│ │ │ ├── logging_config.py # Structured logging
|
||||||
│ │ │ └── middleware.py # Request logging
|
│ │ │ └── middleware.py # Request logging
|
||||||
@@ -198,7 +208,7 @@ patherly/
|
|||||||
│ │ │ ├── auth.ts
|
│ │ │ ├── auth.ts
|
||||||
│ │ │ ├── trees.ts
|
│ │ │ ├── trees.ts
|
||||||
│ │ │ └── sessions.ts
|
│ │ │ └── sessions.ts
|
||||||
│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts)
|
│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts, usePermissions)
|
||||||
│ │ ├── store/
|
│ │ ├── store/
|
||||||
│ │ │ ├── authStore.ts # Zustand auth state
|
│ │ │ ├── authStore.ts # Zustand auth state
|
||||||
│ │ │ ├── themeStore.ts # Dark/light theme
|
│ │ │ ├── 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`.
|
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
|
### findNode Requires Tree Structure Parameter
|
||||||
|
|
||||||
```tsx
|
```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;"`
|
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`
|
2. Install dev deps: `pip install -r requirements-dev.txt`
|
||||||
3. Ensure pytest-asyncio version: `pip install pytest-asyncio==0.24.0`
|
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
|
### API 500 errors
|
||||||
|
|
||||||
|
|||||||
33
backend/alembic/versions/010_add_is_super_admin.py
Normal file
33
backend/alembic/versions/010_add_is_super_admin.py
Normal file
@@ -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')
|
||||||
@@ -75,11 +75,11 @@ async def get_current_active_user(
|
|||||||
async def require_admin(
|
async def require_admin(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Require admin role."""
|
"""Require super admin access."""
|
||||||
if current_user.role != "admin":
|
if not current_user.is_super_admin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Admin access required"
|
detail="Super admin access required"
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
@@ -87,8 +87,10 @@ async def require_admin(
|
|||||||
async def require_engineer_or_admin(
|
async def require_engineer_or_admin(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Require engineer or admin role."""
|
"""Require engineer, team admin, or super admin role (blocks viewers)."""
|
||||||
if current_user.role not in ("admin", "engineer"):
|
if current_user.is_super_admin:
|
||||||
|
return current_user
|
||||||
|
if current_user.role not in ("engineer",):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Engineer or admin access required"
|
detail="Engineer or admin access required"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def slugify(name: str) -> str:
|
|||||||
def can_manage_category(user: User, category: TreeCategory) -> bool:
|
def can_manage_category(user: User, category: TreeCategory) -> bool:
|
||||||
"""Check if user can manage (edit/delete) a category."""
|
"""Check if user can manage (edit/delete) a category."""
|
||||||
# Global admins can manage any category
|
# Global admins can manage any category
|
||||||
if user.role == "admin":
|
if user.is_super_admin:
|
||||||
return True
|
return True
|
||||||
# Team admins can manage their team's categories
|
# Team admins can manage their team's categories
|
||||||
if user.is_team_admin and category.team_id == user.team_id:
|
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:
|
def can_create_category(user: User, team_id: Optional[UUID]) -> bool:
|
||||||
"""Check if user can create a category for the given team."""
|
"""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
|
# 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
|
return True
|
||||||
# Team admins can only create categories for their own team
|
# Team admins can only create categories for their own team
|
||||||
if user.is_team_admin and team_id == user.team_id:
|
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
|
# 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this category"
|
detail="You don't have access to this category"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def can_access_tree(user: User, tree: Tree) -> bool:
|
|||||||
return True
|
return True
|
||||||
if tree.team_id == user.team_id and user.team_id is not None:
|
if tree.team_id == user.team_id and user.team_id is not None:
|
||||||
return True
|
return True
|
||||||
if user.role == "admin":
|
if user.is_super_admin:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ router = APIRouter(prefix="/step-categories", tags=["step-categories"])
|
|||||||
def can_manage_step_category(user: User, category: StepCategory) -> bool:
|
def can_manage_step_category(user: User, category: StepCategory) -> bool:
|
||||||
"""Check if user can manage (edit/delete) a step category."""
|
"""Check if user can manage (edit/delete) a step category."""
|
||||||
# Global admins can manage any category
|
# Global admins can manage any category
|
||||||
if user.role == "admin":
|
if user.is_super_admin:
|
||||||
return True
|
return True
|
||||||
# Team admins can manage their team's categories
|
# Team admins can manage their team's categories
|
||||||
if user.is_team_admin and category.team_id == user.team_id:
|
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:
|
def can_create_step_category(user: User, team_id: Optional[UUID]) -> bool:
|
||||||
"""Check if user can create a step category for the given team."""
|
"""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
|
# 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
|
return True
|
||||||
# Team admins can only create categories for their own team
|
# Team admins can only create categories for their own team
|
||||||
if user.is_team_admin and team_id == user.team_id:
|
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
|
# 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this step category"
|
detail="You don't have access to this step category"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy import select, or_, and_, func, desc, Integer, case
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.user import User
|
||||||
from app.models.step_library import StepLibrary, StepRating
|
from app.models.step_library import StepLibrary, StepRating
|
||||||
from app.models.step_category import StepCategory
|
from app.models.step_category import StepCategory
|
||||||
@@ -33,14 +33,16 @@ def can_view_step(user: User, step: StepLibrary) -> bool:
|
|||||||
if step.visibility == 'private':
|
if step.visibility == 'private':
|
||||||
return step.created_by == user.id
|
return step.created_by == user.id
|
||||||
if step.visibility == 'team':
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
def can_edit_step(user: User, step: StepLibrary) -> bool:
|
def can_edit_step(user: User, step: StepLibrary) -> bool:
|
||||||
"""Check if user can edit/delete a step."""
|
"""Check if user can edit/delete a step."""
|
||||||
if user.role == 'admin':
|
if user.is_super_admin:
|
||||||
return True
|
return True
|
||||||
|
if user.role == 'viewer':
|
||||||
|
return False
|
||||||
return step.created_by == user.id
|
return step.created_by == user.id
|
||||||
|
|
||||||
|
|
||||||
@@ -300,9 +302,9 @@ async def get_step(
|
|||||||
async def create_step(
|
async def create_step(
|
||||||
step_data: StepLibraryCreate,
|
step_data: StepLibraryCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
# Validate category if provided
|
||||||
if step_data.category_id:
|
if step_data.category_id:
|
||||||
cat_result = await db.execute(
|
cat_result = await db.execute(
|
||||||
@@ -316,7 +318,7 @@ async def create_step(
|
|||||||
|
|
||||||
# Team validation: can only set team_id to own team
|
# Team validation: can only set team_id to own team
|
||||||
team_id = step_data.team_id
|
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")
|
raise HTTPException(status_code=403, detail="Cannot create step for another team")
|
||||||
|
|
||||||
step = StepLibrary(
|
step = StepLibrary(
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ def can_manage_tree_tags(user: User, tree: Tree) -> bool:
|
|||||||
"""Check if user can manage tags on a tree.
|
"""Check if user can manage tags on a tree.
|
||||||
|
|
||||||
Allowed:
|
Allowed:
|
||||||
- Tree author
|
- Tree author (engineer+)
|
||||||
- Global admins
|
- Super admins
|
||||||
- Team admins for their team's trees
|
- Team admins for their team's trees
|
||||||
"""
|
"""
|
||||||
if user.id == tree.author_id:
|
if user.is_super_admin:
|
||||||
return True
|
return True
|
||||||
if user.role == "admin":
|
if user.role == "viewer":
|
||||||
|
return False
|
||||||
|
if user.id == tree.author_id:
|
||||||
return True
|
return True
|
||||||
if user.is_team_admin and tree.team_id == user.team_id:
|
if user.is_team_admin and tree.team_id == user.team_id:
|
||||||
return True
|
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:
|
def can_create_tag(user: User, team_id: Optional[UUID]) -> bool:
|
||||||
"""Check if user can create a tag for the given scope.
|
"""Check if user can create a tag for the given scope.
|
||||||
|
|
||||||
- Global admins can create global tags (team_id=None)
|
- Super admins can create global tags (team_id=None)
|
||||||
- Team admins and global admins can create team-specific tags
|
- Team admins and super admins can create team-specific tags
|
||||||
- Regular users can create team tags for their own team
|
- Engineers can create team tags for their own team
|
||||||
|
- Viewers cannot create tags
|
||||||
"""
|
"""
|
||||||
if user.role == "admin":
|
if user.is_super_admin:
|
||||||
return True
|
return True
|
||||||
|
if user.role == "viewer":
|
||||||
|
return False
|
||||||
# For team-specific tags, user must belong to that team
|
# For team-specific tags, user must belong to that team
|
||||||
if team_id is not None and team_id == user.team_id:
|
if team_id is not None and team_id == user.team_id:
|
||||||
return True
|
return True
|
||||||
@@ -133,7 +138,7 @@ async def get_tag(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check access: global tags visible to all, team tags only to team members
|
# 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this tag"
|
detail="You don't have access to this tag"
|
||||||
@@ -231,7 +236,7 @@ async def add_tags_to_tree(
|
|||||||
|
|
||||||
# Try to find existing tag
|
# Try to find existing tag
|
||||||
# Determine scope: use tree's team, or global for admin-owned trees
|
# 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(
|
tag_query = select(TreeTag).where(
|
||||||
TreeTag.slug == slug,
|
TreeTag.slug == slug,
|
||||||
@@ -361,7 +366,7 @@ async def replace_tree_tags(
|
|||||||
tree.tags.clear()
|
tree.tags.clear()
|
||||||
|
|
||||||
# Add new tags
|
# 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:
|
for tag_name in tag_data.tags:
|
||||||
slug = TreeTag.slugify(tag_name)
|
slug = TreeTag.slugify(tag_name)
|
||||||
@@ -428,7 +433,7 @@ async def get_tree_tags(
|
|||||||
if not tree.is_public:
|
if not tree.is_public:
|
||||||
if tree.author_id != current_user.id:
|
if tree.author_id != current_user.id:
|
||||||
if tree.team_id != current_user.team_id:
|
if tree.team_id != current_user.team_id:
|
||||||
if current_user.role != "admin":
|
if not current_user.is_super_admin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this tree"
|
detail="You don't have access to this tree"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
|
|||||||
category_info=category_info,
|
category_info=category_info,
|
||||||
tags=tree.tag_names,
|
tags=tree.tag_names,
|
||||||
author_id=tree.author_id,
|
author_id=tree.author_id,
|
||||||
|
team_id=tree.team_id,
|
||||||
is_active=tree.is_active,
|
is_active=tree.is_active,
|
||||||
is_public=tree.is_public,
|
is_public=tree.is_public,
|
||||||
is_default=tree.is_default,
|
is_default=tree.is_default,
|
||||||
@@ -250,7 +251,7 @@ async def get_tree(
|
|||||||
tree.is_public or
|
tree.is_public or
|
||||||
tree.author_id == current_user.id or
|
tree.author_id == current_user.id or
|
||||||
(tree.team_id == current_user.team_id and current_user.team_id is not None) 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:
|
if not tree.is_active or not can_access:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -274,7 +275,7 @@ async def create_tree(
|
|||||||
- tags: List of tag names to assign (creates new tags if needed)
|
- tags: List of tag names to assign (creates new tags if needed)
|
||||||
"""
|
"""
|
||||||
# Only admins can create default/system trees
|
# 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
|
# Verify category exists if provided
|
||||||
if tree_data.category_id:
|
if tree_data.category_id:
|
||||||
@@ -288,7 +289,7 @@ async def create_tree(
|
|||||||
detail="Category not found"
|
detail="Category not found"
|
||||||
)
|
)
|
||||||
# Check category access
|
# 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this category"
|
detail="You don't have access to this category"
|
||||||
@@ -310,7 +311,7 @@ async def create_tree(
|
|||||||
|
|
||||||
# Handle tags
|
# Handle tags
|
||||||
if tree_data.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
|
# Collect tags to add
|
||||||
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
|
# Check if user can edit: must be author, team admin for team trees, or global admin
|
||||||
can_edit = (
|
can_edit = (
|
||||||
tree.author_id == current_user.id or
|
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)
|
(current_user.is_team_admin and tree.team_id == current_user.team_id)
|
||||||
)
|
)
|
||||||
if not can_edit:
|
if not can_edit:
|
||||||
@@ -425,7 +426,7 @@ async def update_tree(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Category 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this category"
|
detail="You don't have access to this category"
|
||||||
@@ -455,7 +456,7 @@ async def update_tree(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add new tags
|
# 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()
|
added_tag_ids = set()
|
||||||
|
|
||||||
for tag_name in tags_data:
|
for tag_name in tags_data:
|
||||||
|
|||||||
94
backend/app/core/permissions.py
Normal file
94
backend/app/core/permissions.py
Normal file
@@ -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
|
||||||
@@ -25,6 +25,7 @@ class User(Base):
|
|||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
name: 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")
|
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)
|
is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
@@ -50,10 +51,10 @@ class User(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
"""Returns True if user is a global (ResolutionFlow) admin."""
|
"""Returns True if user is a super admin (system-wide access)."""
|
||||||
return self.role == "admin"
|
return self.is_super_admin
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_manage_team(self) -> bool:
|
def can_manage_team(self) -> bool:
|
||||||
"""Returns True if user can manage their team (team admin or global admin)."""
|
"""Returns True if user can manage their team (team admin or super admin)."""
|
||||||
return self.is_admin or (self.is_team_admin and self.team_id is not None)
|
return self.is_super_admin or (self.is_team_admin and self.team_id is not None)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class TreeListResponse(BaseModel):
|
|||||||
category_info: Optional[CategoryInfo] = None
|
category_info: Optional[CategoryInfo] = None
|
||||||
tags: list[str] = [] # List of tag names
|
tags: list[str] = [] # List of tag names
|
||||||
author_id: Optional[UUID] = None
|
author_id: Optional[UUID] = None
|
||||||
|
team_id: Optional[UUID] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_public: bool
|
is_public: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class UserBase(BaseModel):
|
|||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
|
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)")
|
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):
|
class UserResponse(UserBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
role: str
|
role: str
|
||||||
|
is_super_admin: bool = False
|
||||||
is_team_admin: bool = False
|
is_team_admin: bool = False
|
||||||
team_id: Optional[UUID] = None
|
team_id: Optional[UUID] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
91
docs/RBAC-IMPLEMENTATION-QUESTIONS.md
Normal file
91
docs/RBAC-IMPLEMENTATION-QUESTIONS.md
Normal file
@@ -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*
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
|
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { ThemeToggle } from '@/components/common/ThemeToggle'
|
import { ThemeToggle } from '@/components/common/ThemeToggle'
|
||||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||||
import { BrandWordmark } from '@/components/common/BrandWordmark'
|
import { BrandWordmark } from '@/components/common/BrandWordmark'
|
||||||
@@ -9,6 +10,7 @@ export function AppLayout() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
|
const { effectiveRole } = usePermissions()
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
@@ -53,6 +55,20 @@ export function AppLayout() {
|
|||||||
<span className="hidden text-sm text-muted-foreground sm:block">
|
<span className="hidden text-sm text-muted-foreground sm:block">
|
||||||
{user?.name || user?.email}
|
{user?.name || user?.email}
|
||||||
</span>
|
</span>
|
||||||
|
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'hidden rounded-full px-2 py-0.5 text-xs font-medium sm:inline-block',
|
||||||
|
effectiveRole === 'super_admin' && 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||||
|
effectiveRole === 'team_admin' && 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||||
|
effectiveRole === 'viewer' && 'bg-gray-500/10 text-gray-600 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{effectiveRole === 'super_admin' ? 'Super Admin' :
|
||||||
|
effectiveRole === 'team_admin' ? 'Team Admin' :
|
||||||
|
'Viewer'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
75
frontend/src/hooks/usePermissions.ts
Normal file
75
frontend/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Centralized permissions hook for ResolutionFlow.
|
||||||
|
*
|
||||||
|
* Role hierarchy: super_admin > team_admin > engineer > viewer
|
||||||
|
*
|
||||||
|
* Mirrors backend logic in backend/app/core/permissions.py
|
||||||
|
*/
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
export type EffectiveRole = 'super_admin' | 'team_admin' | 'engineer' | 'viewer'
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
|
||||||
|
super_admin: 4,
|
||||||
|
team_admin: 3,
|
||||||
|
engineer: 2,
|
||||||
|
viewer: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveRole(user: User | null): EffectiveRole {
|
||||||
|
if (!user) return 'viewer'
|
||||||
|
if (user.is_super_admin) return 'super_admin'
|
||||||
|
if (user.is_team_admin && user.team_id) return 'team_admin'
|
||||||
|
return user.role as EffectiveRole
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMinimumRole(user: User | null, minimum: EffectiveRole): boolean {
|
||||||
|
const effective = getEffectiveRole(user)
|
||||||
|
return ROLE_HIERARCHY[effective] >= ROLE_HIERARCHY[minimum]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
const effectiveRole = getEffectiveRole(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveRole,
|
||||||
|
isSuperAdmin: effectiveRole === 'super_admin',
|
||||||
|
isTeamAdmin: effectiveRole === 'team_admin' || effectiveRole === 'super_admin',
|
||||||
|
isEngineer: hasMinimumRole(user, 'engineer'),
|
||||||
|
isViewer: effectiveRole === 'viewer',
|
||||||
|
|
||||||
|
// Content creation permissions
|
||||||
|
canCreateTrees: hasMinimumRole(user, 'engineer'),
|
||||||
|
canCreateSteps: hasMinimumRole(user, 'engineer'),
|
||||||
|
|
||||||
|
// Resource-specific checks
|
||||||
|
canEditTree: (tree: { author_id: string | null; team_id?: string | null }) => {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.is_super_admin) return true
|
||||||
|
if (!hasMinimumRole(user, 'engineer')) return false
|
||||||
|
if (tree.author_id && tree.author_id === user.id) return true
|
||||||
|
if (user.is_team_admin && tree.team_id === user.team_id && user.team_id) return true
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
canDeleteTree: (_tree: { author_id: string | null }) => {
|
||||||
|
if (!user) return false
|
||||||
|
return user.is_super_admin
|
||||||
|
},
|
||||||
|
|
||||||
|
canEditStep: (step: { created_by: string }) => {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.is_super_admin) return true
|
||||||
|
if (!hasMinimumRole(user, 'engineer')) return false
|
||||||
|
return step.created_by === user.id
|
||||||
|
},
|
||||||
|
|
||||||
|
// Management permissions
|
||||||
|
canManageCategories: hasMinimumRole(user, 'team_admin'),
|
||||||
|
canManageGlobalCategories: effectiveRole === 'super_admin',
|
||||||
|
canManageTeam: effectiveRole === 'super_admin' || (effectiveRole === 'team_admin'),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorSto
|
|||||||
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||||
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
|
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function TreeEditorPage() {
|
export function TreeEditorPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isEditMode = !!id
|
const isEditMode = !!id
|
||||||
|
const { canCreateTrees } = usePermissions()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
@@ -75,8 +77,17 @@ export function TreeEditorPage() {
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Permission guard: redirect viewers away from editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canCreateTrees) {
|
||||||
|
navigate('/trees')
|
||||||
|
}
|
||||||
|
}, [canCreateTrees, navigate])
|
||||||
|
|
||||||
// Initialize or load tree
|
// Initialize or load tree
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canCreateTrees) return
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -102,7 +113,7 @@ export function TreeEditorPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
}, [id, isEditMode])
|
}, [id, isEditMode, canCreateTrees])
|
||||||
|
|
||||||
// Handle unsaved changes warning
|
// Handle unsaved changes warning
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { FolderSidebar } from '@/components/library/FolderSidebar'
|
|||||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||||
import { AddToFolderMenu } from '@/components/library/AddToFolderMenu'
|
import { AddToFolderMenu } from '@/components/library/AddToFolderMenu'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
|
|
||||||
export function TreeLibraryPage() {
|
export function TreeLibraryPage() {
|
||||||
|
const { canCreateTrees, canEditTree } = usePermissions()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||||
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
||||||
@@ -143,16 +145,18 @@ export function TreeLibraryPage() {
|
|||||||
Select a troubleshooting tree to start a new session
|
Select a troubleshooting tree to start a new session
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
{canCreateTrees && (
|
||||||
to="/trees/new"
|
<Link
|
||||||
className={cn(
|
to="/trees/new"
|
||||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
className={cn(
|
||||||
'hover:bg-primary/90'
|
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||||
)}
|
'hover:bg-primary/90'
|
||||||
>
|
)}
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
Create Tree
|
<Plus className="h-4 w-4" />
|
||||||
</Link>
|
Create Tree
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
@@ -306,16 +310,18 @@ export function TreeLibraryPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={handleCreateFolder} />
|
<AddToFolderMenu treeId={tree.id} onFolderCreated={handleCreateFolder} />
|
||||||
<Link
|
{canEditTree({ author_id: tree.author_id, team_id: tree.team_id }) && (
|
||||||
to={`/trees/${tree.id}/edit`}
|
<Link
|
||||||
className={cn(
|
to={`/trees/${tree.id}/edit`}
|
||||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
className={cn(
|
||||||
'hover:bg-accent hover:text-accent-foreground'
|
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||||
)}
|
'hover:bg-accent hover:text-accent-foreground'
|
||||||
title="Edit tree"
|
)}
|
||||||
>
|
title="Edit tree"
|
||||||
<Pencil className="h-4 w-4" />
|
>
|
||||||
</Link>
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleStartSession(tree.id)}
|
onClick={() => handleStartSession(tree.id)}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface TreeListItem {
|
|||||||
category_info: CategoryInfo | null
|
category_info: CategoryInfo | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
author_id: string | null
|
author_id: string | null
|
||||||
|
team_id: string | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
is_public: boolean
|
is_public: boolean
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export type UserRole = 'admin' | 'engineer' | 'viewer'
|
export type UserRole = 'engineer' | 'viewer'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
is_super_admin: boolean
|
||||||
is_team_admin: boolean
|
is_team_admin: boolean
|
||||||
team_id: string | null
|
team_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
Reference in New Issue
Block a user