Add 4-layer access control stack (subscription → module → menu → permissions): - P1: Wire requires_permission into menu sidebar filtering - P2: Expose window.USER_PERMISSIONS for Alpine.js client-side gating - P3: Add page-level permission guards on store routes - P4: Role CRUD API endpoints and role editor UI - P5: Audit trail for all role/permission changes Includes unit tests (menu permission filtering, role CRUD service) and integration tests (role API endpoints). All 404 core+tenancy tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
571 lines
15 KiB
Python
571 lines
15 KiB
Python
# app/modules/tenancy/routes/api/store_team.py
|
|
"""
|
|
Store team member management endpoints.
|
|
|
|
Implements complete team management with:
|
|
- Team member listing
|
|
- Invitation system
|
|
- Role management
|
|
- Permission checking
|
|
- RBAC integration
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import (
|
|
get_current_store_api,
|
|
get_user_permissions,
|
|
require_store_owner,
|
|
require_store_permission,
|
|
)
|
|
from app.core.database import get_db
|
|
from app.modules.tenancy.schemas.team import (
|
|
BulkRemoveRequest,
|
|
BulkRemoveResponse,
|
|
InvitationAccept,
|
|
InvitationAcceptResponse,
|
|
InvitationResponse,
|
|
RoleCreate,
|
|
RoleListResponse,
|
|
RoleResponse,
|
|
RoleUpdate,
|
|
TeamMemberInvite,
|
|
TeamMemberListResponse,
|
|
TeamMemberResponse,
|
|
TeamMemberUpdate,
|
|
TeamStatistics,
|
|
UserPermissionsResponse,
|
|
)
|
|
|
|
# Permission IDs are now defined in module definition.py files
|
|
# and discovered by PermissionDiscoveryService
|
|
from app.modules.tenancy.services.store_team_service import store_team_service
|
|
from models.schema.auth import UserContext
|
|
|
|
store_team_router = APIRouter(prefix="/team")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Team Member Routes
|
|
# ============================================================================
|
|
|
|
|
|
@store_team_router.get("/members", response_model=TeamMemberListResponse)
|
|
def list_team_members(
|
|
request: Request,
|
|
include_inactive: bool = False,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(
|
|
require_store_permission("team.view")
|
|
),
|
|
):
|
|
"""
|
|
Get all team members for current store.
|
|
|
|
**Required Permission:** `team.view`
|
|
|
|
**Query Parameters:**
|
|
- `include_inactive`: Include inactive team members (default: False)
|
|
|
|
**Returns:**
|
|
- List of team members with their roles and permissions
|
|
- Statistics (total, active, pending)
|
|
"""
|
|
store = request.state.store
|
|
|
|
members = store_team_service.get_team_members(
|
|
db=db, store=store, include_inactive=include_inactive
|
|
)
|
|
|
|
# Calculate statistics
|
|
total = len(members)
|
|
active = sum(1 for m in members if m["is_active"])
|
|
pending = sum(1 for m in members if m["invitation_pending"])
|
|
|
|
logger.info(
|
|
f"Listed {total} team members for store {store.store_code} "
|
|
f"(active: {active}, pending: {pending})"
|
|
)
|
|
|
|
return TeamMemberListResponse(
|
|
members=members, total=total, active_count=active, pending_invitations=pending
|
|
)
|
|
|
|
|
|
@store_team_router.post("/invite", response_model=InvitationResponse)
|
|
def invite_team_member(
|
|
invitation: TeamMemberInvite,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner), # Owner only
|
|
):
|
|
"""
|
|
Invite a new team member to the store.
|
|
|
|
**Required:** Store owner role
|
|
|
|
**Process:**
|
|
1. Create user account (if doesn't exist)
|
|
2. Create StoreUser with invitation token
|
|
3. Send invitation email
|
|
|
|
**Request Body:**
|
|
- `email`: Email address of invitee
|
|
- `first_name`, `last_name`: Optional names
|
|
- `role_name`: Preset role (manager, staff, support, viewer, marketing)
|
|
- `role_id`: Use existing role (alternative to role_name)
|
|
- `custom_permissions`: Override role permissions (requires role_name)
|
|
|
|
**Returns:**
|
|
- Invitation details
|
|
- Confirmation of email sent
|
|
"""
|
|
store = request.state.store
|
|
|
|
# Determine role approach
|
|
if invitation.role_id:
|
|
# Use existing role by ID
|
|
result = store_team_service.invite_team_member(
|
|
db=db,
|
|
store=store,
|
|
inviter=current_user,
|
|
email=invitation.email,
|
|
role_id=invitation.role_id,
|
|
)
|
|
elif invitation.role_name:
|
|
# Use role name with optional custom permissions
|
|
result = store_team_service.invite_team_member(
|
|
db=db,
|
|
store=store,
|
|
inviter=current_user,
|
|
email=invitation.email,
|
|
role_name=invitation.role_name,
|
|
custom_permissions=invitation.custom_permissions,
|
|
)
|
|
else:
|
|
# Default to Staff role
|
|
result = store_team_service.invite_team_member(
|
|
db=db,
|
|
store=store,
|
|
inviter=current_user,
|
|
email=invitation.email,
|
|
role_name="staff",
|
|
)
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"Invitation sent: {invitation.email} to {store.store_code} "
|
|
f"by {current_user.username}"
|
|
)
|
|
|
|
return InvitationResponse(
|
|
message="Invitation sent successfully",
|
|
email=result["email"],
|
|
role=result["role"],
|
|
invitation_sent=True,
|
|
)
|
|
|
|
|
|
@store_team_router.post("/accept-invitation", response_model=InvitationAcceptResponse) # public
|
|
def accept_invitation(acceptance: InvitationAccept, db: Session = Depends(get_db)):
|
|
"""
|
|
Accept a team invitation and activate account.
|
|
|
|
**No authentication required** - uses invitation token.
|
|
|
|
**Request Body:**
|
|
- `invitation_token`: Token from invitation email
|
|
- `password`: New password (min 8 chars, must have upper, lower, digit)
|
|
- `first_name`, `last_name`: User's name
|
|
|
|
**Returns:**
|
|
- Confirmation message
|
|
- Store information
|
|
- User information
|
|
- Assigned role
|
|
"""
|
|
result = store_team_service.accept_invitation(
|
|
db=db,
|
|
invitation_token=acceptance.invitation_token,
|
|
password=acceptance.password,
|
|
first_name=acceptance.first_name,
|
|
last_name=acceptance.last_name,
|
|
)
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"Invitation accepted: {result['user'].email} "
|
|
f"for store {result['store'].store_code}"
|
|
)
|
|
|
|
return InvitationAcceptResponse(
|
|
message="Invitation accepted successfully. You can now login.",
|
|
store={
|
|
"id": result["store"].id,
|
|
"store_code": result["store"].store_code,
|
|
"name": result["store"].name,
|
|
"subdomain": result["store"].subdomain,
|
|
},
|
|
user={
|
|
"id": result["user"].id,
|
|
"email": result["user"].email,
|
|
"username": result["user"].username,
|
|
"full_name": result["user"].full_name,
|
|
},
|
|
role=result["role"],
|
|
)
|
|
|
|
|
|
@store_team_router.get("/members/{user_id}", response_model=TeamMemberResponse)
|
|
def get_team_member(
|
|
user_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(
|
|
require_store_permission("team.view")
|
|
),
|
|
):
|
|
"""
|
|
Get details of a specific team member.
|
|
|
|
**Required Permission:** `team.view`
|
|
"""
|
|
store = request.state.store
|
|
|
|
members = store_team_service.get_team_members(
|
|
db=db, store=store, include_inactive=True
|
|
)
|
|
|
|
member = next((m for m in members if m["id"] == user_id), None)
|
|
if not member:
|
|
from app.modules.tenancy.exceptions import UserNotFoundException
|
|
|
|
raise UserNotFoundException(str(user_id))
|
|
|
|
return TeamMemberResponse(**member)
|
|
|
|
|
|
@store_team_router.put("/members/{user_id}", response_model=TeamMemberResponse)
|
|
def update_team_member(
|
|
user_id: int,
|
|
update_data: TeamMemberUpdate,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner), # Owner only
|
|
):
|
|
"""
|
|
Update a team member's role or status.
|
|
|
|
**Required:** Store owner role
|
|
|
|
**Cannot:**
|
|
- Change owner's role
|
|
- Remove owner
|
|
|
|
**Request Body:**
|
|
- `role_id`: New role ID (optional)
|
|
- `is_active`: Active status (optional)
|
|
"""
|
|
store = request.state.store
|
|
|
|
store_team_service.update_member_role(
|
|
db=db,
|
|
store=store,
|
|
user_id=user_id,
|
|
new_role_id=update_data.role_id,
|
|
is_active=update_data.is_active,
|
|
)
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"Team member updated: {user_id} in {store.store_code} "
|
|
f"by {current_user.username}"
|
|
)
|
|
|
|
# Return updated member details
|
|
members = store_team_service.get_team_members(db, store, include_inactive=True)
|
|
member = next((m for m in members if m["id"] == user_id), None)
|
|
|
|
return TeamMemberResponse(**member)
|
|
|
|
|
|
@store_team_router.delete("/members/{user_id}")
|
|
def remove_team_member(
|
|
user_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner), # Owner only
|
|
):
|
|
"""
|
|
Remove a team member from the store.
|
|
|
|
**Required:** Store owner role
|
|
|
|
**Cannot remove:**
|
|
- Store owner
|
|
|
|
**Action:**
|
|
- Soft delete (sets is_active = False)
|
|
- Member can be re-invited later
|
|
"""
|
|
store = request.state.store
|
|
|
|
store_team_service.remove_team_member(db=db, store=store, user_id=user_id)
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"Team member removed: {user_id} from {store.store_code} "
|
|
f"by {current_user.username}"
|
|
)
|
|
|
|
return {"message": "Team member removed successfully", "user_id": user_id}
|
|
|
|
|
|
@store_team_router.post("/members/bulk-remove", response_model=BulkRemoveResponse)
|
|
def bulk_remove_team_members(
|
|
bulk_remove: BulkRemoveRequest,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner),
|
|
):
|
|
"""
|
|
Remove multiple team members at once.
|
|
|
|
**Required:** Store owner role
|
|
"""
|
|
store = request.state.store
|
|
|
|
success_count = 0
|
|
failed_count = 0
|
|
errors = []
|
|
|
|
for user_id in bulk_remove.user_ids:
|
|
try:
|
|
store_team_service.remove_team_member(
|
|
db=db, store=store, user_id=user_id
|
|
)
|
|
success_count += 1
|
|
except Exception as e:
|
|
failed_count += 1
|
|
errors.append({"user_id": user_id, "error": str(e)})
|
|
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"Bulk remove completed: {success_count} removed, {failed_count} failed "
|
|
f"in {store.store_code}"
|
|
)
|
|
|
|
return BulkRemoveResponse(
|
|
success_count=success_count, failed_count=failed_count, errors=errors
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Role Management Routes
|
|
# ============================================================================
|
|
|
|
|
|
@store_team_router.get("/roles", response_model=RoleListResponse)
|
|
def list_roles(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(
|
|
require_store_permission("team.view")
|
|
),
|
|
):
|
|
"""
|
|
Get all available roles for the store.
|
|
|
|
**Required Permission:** `team.view`
|
|
|
|
**Returns:**
|
|
- List of roles with permissions
|
|
- Includes both preset and custom roles
|
|
"""
|
|
store = request.state.store
|
|
|
|
roles = store_team_service.get_store_roles(db=db, store_id=store.id)
|
|
db.commit() # Commit in case default roles were created
|
|
|
|
return RoleListResponse(roles=roles, total=len(roles))
|
|
|
|
|
|
@store_team_router.post("/roles", response_model=RoleResponse, status_code=201)
|
|
def create_role(
|
|
request: Request,
|
|
role_data: RoleCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner),
|
|
):
|
|
"""
|
|
Create a custom role for the store.
|
|
|
|
**Required:** Store owner only.
|
|
|
|
Preset role names (manager, staff, support, viewer, marketing) cannot be used.
|
|
"""
|
|
store = request.state.store
|
|
|
|
role = store_team_service.create_custom_role(
|
|
db=db,
|
|
store_id=store.id,
|
|
name=role_data.name,
|
|
permissions=role_data.permissions,
|
|
actor_user_id=current_user.id,
|
|
)
|
|
db.commit()
|
|
return role
|
|
|
|
|
|
@store_team_router.put("/roles/{role_id}", response_model=RoleResponse)
|
|
def update_role(
|
|
request: Request,
|
|
role_id: int,
|
|
role_data: RoleUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner),
|
|
):
|
|
"""
|
|
Update a role's name and/or permissions.
|
|
|
|
**Required:** Store owner only.
|
|
"""
|
|
store = request.state.store
|
|
|
|
role = store_team_service.update_role(
|
|
db=db,
|
|
store_id=store.id,
|
|
role_id=role_id,
|
|
name=role_data.name,
|
|
permissions=role_data.permissions,
|
|
actor_user_id=current_user.id,
|
|
)
|
|
db.commit()
|
|
return role
|
|
|
|
|
|
@store_team_router.delete("/roles/{role_id}", status_code=204)
|
|
def delete_role(
|
|
request: Request,
|
|
role_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(require_store_owner),
|
|
):
|
|
"""
|
|
Delete a custom role.
|
|
|
|
**Required:** Store owner only.
|
|
|
|
Preset roles cannot be deleted.
|
|
Roles with assigned team members cannot be deleted.
|
|
"""
|
|
store = request.state.store
|
|
|
|
store_team_service.delete_role(
|
|
db=db,
|
|
store_id=store.id,
|
|
role_id=role_id,
|
|
actor_user_id=current_user.id,
|
|
)
|
|
db.commit()
|
|
|
|
|
|
# ============================================================================
|
|
# Permission Routes
|
|
# ============================================================================
|
|
|
|
|
|
@store_team_router.get("/me/permissions", response_model=UserPermissionsResponse)
|
|
def get_my_permissions(
|
|
request: Request,
|
|
permissions: list[str] = Depends(get_user_permissions),
|
|
current_user: UserContext = Depends(get_current_store_api),
|
|
):
|
|
"""
|
|
Get current user's permissions in this store.
|
|
|
|
**Use this endpoint to:**
|
|
- Determine what UI elements to show/hide
|
|
- Check permissions before making API calls
|
|
- Display user's role and capabilities
|
|
|
|
**Returns:**
|
|
- Complete list of permissions
|
|
- Whether user is owner
|
|
- Role name (if team member)
|
|
|
|
Requires Authorization header (API endpoint).
|
|
"""
|
|
store = request.state.store
|
|
|
|
is_owner = current_user.is_owner_of(store.id)
|
|
role_name = current_user.get_store_role(store.id)
|
|
|
|
return UserPermissionsResponse(
|
|
permissions=permissions,
|
|
permission_count=len(permissions),
|
|
is_owner=is_owner,
|
|
role_name=role_name,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Statistics Routes
|
|
# ============================================================================
|
|
|
|
|
|
@store_team_router.get("/statistics", response_model=TeamStatistics)
|
|
def get_team_statistics(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: UserContext = Depends(
|
|
require_store_permission("team.view")
|
|
),
|
|
):
|
|
"""
|
|
Get team statistics for the store.
|
|
|
|
**Required Permission:** `team.view`
|
|
|
|
**Returns:**
|
|
- Total members
|
|
- Active/inactive breakdown
|
|
- Pending invitations
|
|
- Owner count
|
|
- Role distribution
|
|
"""
|
|
store = request.state.store
|
|
|
|
members = store_team_service.get_team_members(
|
|
db=db, store=store, include_inactive=True
|
|
)
|
|
|
|
# Calculate statistics
|
|
total = len(members)
|
|
active = sum(1 for m in members if m["is_active"])
|
|
inactive = total - active
|
|
pending = sum(1 for m in members if m["invitation_pending"])
|
|
owners = sum(1 for m in members if m["is_owner"])
|
|
team_members = total - owners
|
|
|
|
# Role breakdown
|
|
roles_breakdown = {}
|
|
for member in members:
|
|
role = member["role_name"]
|
|
roles_breakdown[role] = roles_breakdown.get(role, 0) + 1
|
|
|
|
return TeamStatistics(
|
|
total_members=total,
|
|
active_members=active,
|
|
inactive_members=inactive,
|
|
pending_invitations=pending,
|
|
owners=owners,
|
|
team_members=team_members,
|
|
roles_breakdown=roles_breakdown,
|
|
)
|