feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
CI / ruff (push) Successful in 9s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running

- Add admin store roles page with merchant→store cascading for superadmin
  and store-only selection for platform admin
- Add permission catalog API with translated labels/descriptions (en/fr/de/lb)
- Add permission translations to all 15 module locale files (60 files total)
- Add info icon tooltips for permission descriptions in role editor
- Add store roles menu item and admin menu item in module definition
- Fix store-selector.js URL construction bug when apiEndpoint has query params
- Add admin store roles API (CRUD + platform scoping)
- Add integration tests for admin store roles and permission catalog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 23:31:27 +01:00
parent 2b55e7458b
commit f95db7c0b1
83 changed files with 3491 additions and 513 deletions

View File

@@ -87,9 +87,11 @@ tenancy_module = ModuleDefinition(
"stores",
"admin-users",
"merchant-users",
"store-roles",
],
FrontendType.STORE: [
"team",
"roles",
],
FrontendType.MERCHANT: [
"stores",
@@ -122,6 +124,13 @@ tenancy_module = ModuleDefinition(
order=20,
is_mandatory=True,
),
MenuItemDefinition(
id="store-roles",
label_key="tenancy.menu.store_roles",
icon="shield-check",
route="/admin/store-roles",
order=30,
),
],
),
MenuSectionDefinition(
@@ -202,6 +211,14 @@ tenancy_module = ModuleDefinition(
route="/store/{store_code}/team",
order=5,
),
MenuItemDefinition(
id="roles",
label_key="tenancy.menu.roles",
icon="shield-check",
route="/store/{store_code}/team/roles",
order=10,
requires_permission="team.view",
),
],
),
],

View File

@@ -112,5 +112,37 @@
"name": "Audit-Protokoll",
"description": "Alle Benutzeraktionen und Änderungen nachverfolgen"
}
},
"permissions": {
"category": {
"dashboard": "Dashboard",
"products": "Produkte",
"stock": "Inventar",
"orders": "Bestellungen",
"customers": "Kunden",
"marketing": "Marketing",
"reports": "Berichte",
"settings": "Einstellungen",
"team": "Team",
"imports": "Importe",
"general": "Allgemein",
"analytics": "Analytik",
"billing": "Abrechnung",
"cart": "Warenkorb",
"checkout": "Kasse",
"cms": "Inhalte",
"loyalty": "Treue",
"marketplace": "Marktplatz",
"messaging": "Nachrichten",
"payments": "Zahlungen"
},
"team_view": "Team anzeigen",
"team_view_desc": "Teammitglieder und ihre Rollen anzeigen",
"team_invite": "Mitglieder einladen",
"team_invite_desc": "Neue Mitglieder ins Team einladen",
"team_edit": "Mitglieder bearbeiten",
"team_edit_desc": "Rollen und Berechtigungen der Mitglieder bearbeiten",
"team_remove": "Mitglieder entfernen",
"team_remove_desc": "Mitglieder aus dem Team entfernen"
}
}

View File

@@ -97,6 +97,38 @@
"show_all_menu_items": "This will show all menu items. Continue?",
"hide_all_menu_items": "This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?"
},
"permissions": {
"category": {
"dashboard": "Dashboard",
"products": "Products",
"stock": "Inventory",
"orders": "Orders",
"customers": "Customers",
"marketing": "Marketing",
"reports": "Reports",
"settings": "Settings",
"team": "Team",
"imports": "Imports",
"general": "General",
"analytics": "Analytics",
"billing": "Billing",
"cart": "Cart",
"checkout": "Checkout",
"cms": "Content",
"loyalty": "Loyalty",
"marketplace": "Marketplace",
"messaging": "Messaging",
"payments": "Payments"
},
"team_view": "View Team",
"team_view_desc": "View team members and their roles",
"team_invite": "Invite Members",
"team_invite_desc": "Invite new members to the store team",
"team_edit": "Edit Members",
"team_edit_desc": "Edit team member roles and permissions",
"team_remove": "Remove Members",
"team_remove_desc": "Remove members from the store team"
},
"features": {
"team_members": {
"name": "Team Members",

View File

@@ -112,5 +112,37 @@
"name": "Journal d'audit",
"description": "Suivre toutes les actions et modifications"
}
},
"permissions": {
"category": {
"dashboard": "Tableau de bord",
"products": "Produits",
"stock": "Inventaire",
"orders": "Commandes",
"customers": "Clients",
"marketing": "Marketing",
"reports": "Rapports",
"settings": "Paramètres",
"team": "Équipe",
"imports": "Importations",
"general": "Général",
"analytics": "Analytique",
"billing": "Facturation",
"cart": "Panier",
"checkout": "Paiement",
"cms": "Contenu",
"loyalty": "Fidélité",
"marketplace": "Marketplace",
"messaging": "Messagerie",
"payments": "Paiements"
},
"team_view": "Voir l'équipe",
"team_view_desc": "Voir les membres de l'équipe et leurs rôles",
"team_invite": "Inviter des membres",
"team_invite_desc": "Inviter de nouveaux membres dans l'équipe",
"team_edit": "Modifier les membres",
"team_edit_desc": "Modifier les rôles et permissions des membres",
"team_remove": "Supprimer des membres",
"team_remove_desc": "Retirer des membres de l'équipe"
}
}

View File

@@ -112,5 +112,37 @@
"name": "Audit-Protokoll",
"description": "All Benotzeraktiounen an Ännerungen nospueren"
}
},
"permissions": {
"category": {
"dashboard": "Dashboard",
"products": "Produiten",
"stock": "Inventar",
"orders": "Bestellungen",
"customers": "Clienten",
"marketing": "Marketing",
"reports": "Berichter",
"settings": "Astellungen",
"team": "Team",
"imports": "Importatiounen",
"general": "Allgemeng",
"analytics": "Analytik",
"billing": "Ofrechnung",
"cart": "Kuerf",
"checkout": "Keess",
"cms": "Inhalter",
"loyalty": "Treiheet",
"marketplace": "Marché",
"messaging": "Messagen",
"payments": "Bezuelungen"
},
"team_view": "Team kucken",
"team_view_desc": "Team-Memberen an hir Rollen kucken",
"team_invite": "Memberen invitéieren",
"team_invite_desc": "Nei Memberen an d'Team invitéieren",
"team_edit": "Memberen änneren",
"team_edit_desc": "Rollen a Rechter vun de Memberen änneren",
"team_remove": "Memberen ewechhuelen",
"team_remove_desc": "Memberen aus dem Team ewechhuelen"
}
}

View File

@@ -26,6 +26,7 @@ from .admin_modules import router as admin_modules_router
from .admin_platform_users import admin_platform_users_router
from .admin_platforms import admin_platforms_router
from .admin_store_domains import admin_store_domains_router
from .admin_store_roles import admin_store_roles_router
from .admin_stores import admin_stores_router
from .admin_users import admin_users_router
@@ -39,6 +40,7 @@ admin_router.include_router(admin_merchants_router, tags=["admin-merchants"])
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
admin_router.include_router(admin_stores_router, tags=["admin-stores"])
admin_router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
admin_router.include_router(admin_store_roles_router, tags=["admin-store-roles"])
admin_router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"])
admin_router.include_router(admin_modules_router, tags=["admin-modules"])
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])

View File

@@ -0,0 +1,181 @@
# app/modules/tenancy/routes/api/admin_store_roles.py
"""
Admin store role management endpoints.
Allows super admins and platform admins to manage roles for any store
they have access to. Platform admins are scoped to stores within their
assigned platforms.
Endpoints:
GET /admin/store-roles — List roles for a store
GET /admin/store-roles/permissions/catalog — Permission catalog
POST /admin/store-roles — Create a role
PUT /admin/store-roles/{role_id} — Update a role
DELETE /admin/store-roles/{role_id} — Delete a role
"""
import logging
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.schemas.team import (
PermissionCatalogResponse,
RoleCreate,
RoleListResponse,
RoleResponse,
RoleUpdate,
)
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
from app.modules.tenancy.services.store_team_service import store_team_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
admin_store_roles_router = APIRouter(prefix="/store-roles")
logger = logging.getLogger(__name__)
@admin_store_roles_router.get(
"/permissions/catalog", response_model=PermissionCatalogResponse
)
def admin_get_permission_catalog(
request: Request,
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get the full permission catalog grouped by category.
Available to all admin users. Returns all permission definitions
with labels and descriptions for the role editor UI.
"""
categories = permission_discovery_service.get_permissions_by_category()
lang = current_admin.preferred_language or getattr(
request.state, "language", "en"
)
def _t(key: str) -> str:
"""Translate key, falling back to readable version."""
translated = translate(key, language=lang)
if translated == key:
parts = key.split(".")
return parts[-1].replace("_", " ").title()
return translated
return PermissionCatalogResponse(
categories=[
{
"id": cat.id,
"label": _t(cat.label_key),
"permissions": [
{
"id": p.id,
"label": _t(p.label_key),
"description": _t(p.description_key),
"is_owner_only": p.is_owner_only,
}
for p in cat.permissions
],
}
for cat in categories
]
)
@admin_store_roles_router.get("", response_model=RoleListResponse)
def admin_list_store_roles(
store_id: int = Query(..., description="Store ID to list roles for"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
List all roles for a store.
Platform admins can only access stores within their assigned platforms.
Super admins can access any store.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
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))
@admin_store_roles_router.post("", response_model=RoleResponse, status_code=201)
def admin_create_store_role(
role_data: RoleCreate,
store_id: int = Query(..., description="Store ID to create role for"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create a custom role for a store.
Platform admins can only manage stores within their assigned platforms.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
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_admin.id,
)
db.commit()
return role
@admin_store_roles_router.put("/{role_id}", response_model=RoleResponse)
def admin_update_store_role(
role_id: int,
role_data: RoleUpdate,
store_id: int = Query(..., description="Store ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update a role's name and/or permissions.
Platform admins can only manage stores within their assigned platforms.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
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_admin.id,
)
db.commit()
return role
@admin_store_roles_router.delete("/{role_id}", status_code=204)
def admin_delete_store_role(
role_id: int,
store_id: int = Query(..., description="Store ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Delete a custom role.
Preset roles cannot be deleted. Platform admins can only manage
stores within their assigned platforms.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
store_team_service.delete_role(
db=db,
store_id=store_id,
role_id=role_id,
actor_user_id=current_admin.id,
)
db.commit()

View File

@@ -86,6 +86,7 @@ def get_all_stores_admin(
search: str | None = Query(None, description="Search by name or store code"),
is_active: bool | None = Query(None),
is_verified: bool | None = Query(None),
merchant_id: int | None = Query(None, description="Filter by merchant ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
@@ -97,6 +98,7 @@ def get_all_stores_admin(
search=search,
is_active=is_active,
is_verified=is_verified,
merchant_id=merchant_id,
)
return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit)

View File

@@ -28,6 +28,7 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
PermissionCatalogResponse,
RoleCreate,
RoleListResponse,
RoleResponse,
@@ -42,7 +43,11 @@ from app.modules.tenancy.schemas.team import (
# Permission IDs are now defined in module definition.py files
# and discovered by PermissionDiscoveryService
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
from app.modules.tenancy.services.store_team_service import store_team_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
store_team_router = APIRouter(prefix="/team")
@@ -480,6 +485,57 @@ def delete_role(
# ============================================================================
@store_team_router.get(
"/permissions/catalog", response_model=PermissionCatalogResponse
)
def get_permission_catalog(
request: Request,
current_user: UserContext = Depends(
require_store_permission("team.view")
),
):
"""
Get the full permission catalog grouped by category.
**Required Permission:** `team.view`
Returns all available permissions with labels and descriptions,
grouped by category. Used by the role editor UI for displaying
permission checkboxes with human-readable names and tooltips.
"""
categories = permission_discovery_service.get_permissions_by_category()
lang = current_user.preferred_language or getattr(
request.state, "language", "en"
)
def _t(key: str) -> str:
"""Translate key, falling back to readable version."""
translated = translate(key, language=lang)
if translated == key:
parts = key.split(".")
return parts[-1].replace("_", " ").title()
return translated
return PermissionCatalogResponse(
categories=[
{
"id": cat.id,
"label": _t(cat.label_key),
"permissions": [
{
"id": p.id,
"label": _t(p.label_key),
"description": _t(p.description_key),
"is_owner_only": p.is_owner_only,
}
for p in cat.permissions
],
}
for cat in categories
]
)
@store_team_router.get("/me/permissions", response_model=UserPermissionsResponse)
def get_my_permissions(
request: Request,

View File

@@ -197,6 +197,34 @@ async def admin_store_domains_page(
)
# ============================================================================
# STORE ROLES ROUTES
# ============================================================================
@router.get("/store-roles", response_class=HTMLResponse, include_in_schema=False)
async def admin_store_roles_page(
request: Request,
current_user: User = Depends(
require_menu_access("store-roles", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render store roles management page.
Allows admins to select a store and manage its roles and permissions.
Super admins see merchant → store cascading selection.
Platform admins see store selection scoped to their platforms.
"""
is_super_admin = current_user.role == "super_admin"
return templates.TemplateResponse(
"tenancy/admin/store-roles.html",
get_admin_context(
request, db, current_user, is_super_admin=is_super_admin
),
)
# ============================================================================
# STORE THEMES ROUTES
# ============================================================================

View File

@@ -276,6 +276,34 @@ class UserPermissionsResponse(BaseModel):
role_name: str | None = None
# ============================================================================
# Permission Catalog Schemas
# ============================================================================
class PermissionCatalogItem(BaseModel):
"""A single permission with its metadata for UI display."""
id: str
label: str
description: str
is_owner_only: bool = False
class PermissionCategoryResponse(BaseModel):
"""A category of related permissions."""
id: str
label: str
permissions: list[PermissionCatalogItem]
class PermissionCatalogResponse(BaseModel):
"""Complete permission catalog grouped by category."""
categories: list[PermissionCategoryResponse]
# ============================================================================
# Error Response Schema
# ============================================================================

View File

@@ -476,12 +476,17 @@ class AdminService:
search: str | None = None,
is_active: bool | None = None,
is_verified: bool | None = None,
merchant_id: int | None = None,
) -> tuple[list[Store], int]:
"""Get paginated list of all stores with filtering."""
try:
# Eagerly load merchant relationship to avoid N+1 queries
query = db.query(Store).options(joinedload(Store.merchant))
# Filter by merchant
if merchant_id is not None:
query = query.filter(Store.merchant_id == merchant_id)
# Apply search filter
if search:
search_term = f"%{search}%"
@@ -501,6 +506,8 @@ class AdminService:
# Get total count (without joinedload for performance)
count_query = db.query(Store)
if merchant_id is not None:
count_query = count_query.filter(Store.merchant_id == merchant_id)
if search:
search_term = f"%{search}%"
count_query = count_query.filter(

View File

@@ -35,7 +35,7 @@ from app.modules.tenancy.exceptions import (
TeamMemberAlreadyExistsException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, User
from app.modules.tenancy.models import Role, Store, StorePlatform, StoreUser, User
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
@@ -724,6 +724,59 @@ class StoreTeamService:
details={"role_name": role_name, "store_id": store_id},
)
# ========================================================================
# Admin Access Validation
# ========================================================================
def validate_admin_store_access(
self,
db: Session,
user_context,
store_id: int,
) -> Store:
"""
Verify an admin user can access the given store.
Super admins can access any store. Platform admins can only access
stores that belong to one of their assigned platforms.
Args:
db: Database session
user_context: UserContext of the admin user
store_id: Store ID to validate access to
Returns:
The Store object if access is granted
Raises:
InvalidRoleException: If store not found or admin lacks access
"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise InvalidRoleException(f"Store {store_id} not found")
# Super admins (accessible_platform_ids is None) can access all stores
platform_ids = user_context.get_accessible_platform_ids()
if platform_ids is None:
return store
# Platform admins: store must belong to one of their platforms
store_in_platform = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.platform_id.in_(platform_ids),
StorePlatform.is_active == True,
)
.first()
)
if not store_in_platform:
raise InvalidRoleException(
"You do not have access to this store's roles"
)
return store
# Private helper methods
def _generate_invitation_token(self) -> str:

View File

@@ -0,0 +1,419 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/store-roles.js
/**
* Admin store roles management page
*
* Super admins: merchant → store cascading selection.
* Platform admins: store selection scoped to their platforms.
*
* Uses Tom Select for selection and permission catalog API for
* displaying permissions with labels and descriptions.
*/
const storeRolesAdminLog = (window.LogConfig && window.LogConfig.createLogger)
? window.LogConfig.createLogger('adminStoreRoles', false)
: console;
storeRolesAdminLog.info('Loading...');
function adminStoreRoles() {
storeRolesAdminLog.info('adminStoreRoles() called');
const config = window._adminStoreRolesConfig || {};
const isSuperAdmin = config.isSuperAdmin || false;
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'store-roles',
// Selection state
isSuperAdmin,
selectedMerchant: null,
selectedStore: null,
merchantSelector: null,
storeSelector: null,
// Role state
loading: false,
roles: [],
rolesLoading: false,
saving: false,
showRoleModal: false,
editingRole: null,
roleForm: { name: '', permissions: [] },
permissionCategories: [],
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() {
// Guard against multiple initialization
if (window._adminStoreRolesInitialized) {
return;
}
window._adminStoreRolesInitialized = true;
storeRolesAdminLog.info('Admin Store Roles init(), isSuperAdmin:', isSuperAdmin);
this.$nextTick(() => {
if (isSuperAdmin) {
this.initMerchantSelector();
} else {
this.initStoreSelector();
}
});
// Load permission catalog
await this.loadPermissionCatalog();
// Restore saved selection
const savedStoreId = localStorage.getItem('admin_store_roles_selected_store_id');
if (savedStoreId) {
storeRolesAdminLog.info('Restoring saved store:', savedStoreId);
setTimeout(async () => {
await this.restoreSavedStore(parseInt(savedStoreId));
}, 300);
}
storeRolesAdminLog.info('Admin Store Roles initialization complete');
},
// =====================================================================
// Permission Catalog
// =====================================================================
async loadPermissionCatalog() {
try {
const response = await apiClient.get('/admin/store-roles/permissions/catalog');
this.permissionCategories = response.categories || [];
storeRolesAdminLog.info('Loaded permission catalog:', this.permissionCategories.length, 'categories');
} catch (error) {
storeRolesAdminLog.warn('Failed to load permission catalog:', error);
}
},
// =====================================================================
// Merchant Selector (Super Admin only)
// =====================================================================
initMerchantSelector() {
const el = this.$refs.merchantSelect;
if (!el) {
storeRolesAdminLog.warn('Merchant select element not found');
return;
}
const self = this;
waitForTomSelect(() => {
self.merchantSelector = new TomSelect(el, {
valueField: 'id',
labelField: 'name',
searchField: ['name'],
maxOptions: 50,
placeholder: 'Search merchant by name...',
load: async function(query, callback) {
if (query.length < 2) { callback([]); return; }
try {
const response = await apiClient.get(
`/admin/merchants?search=${encodeURIComponent(query)}&limit=50`
);
const merchants = (response.merchants || []).map(m => ({
id: m.id,
name: m.name,
store_count: m.store_count || 0,
}));
callback(merchants);
} catch (error) {
storeRolesAdminLog.error('Merchant search failed:', error);
callback([]);
}
},
render: {
option: function(data, escape) {
return `<div class="flex justify-between items-center py-1">
<span class="font-medium">${escape(data.name)}</span>
<span class="text-xs text-gray-400 ml-2">${data.store_count} store(s)</span>
</div>`;
},
item: function(data, escape) {
return `<div>${escape(data.name)}</div>`;
},
no_results: function() {
return '<div class="no-results py-2 px-3 text-gray-500">No merchants found</div>';
},
loading: function() {
return '<div class="loading py-2 px-3 text-gray-500">Searching...</div>';
}
},
onChange: function(value) {
if (value) {
const selected = this.options[value];
if (selected) {
self.onMerchantSelected({
id: parseInt(value),
name: selected.name,
store_count: selected.store_count,
});
}
} else {
self.onMerchantCleared();
}
},
loadThrottle: 150,
closeAfterSelect: true,
persist: true,
create: false,
});
storeRolesAdminLog.info('Merchant selector initialized');
});
},
async onMerchantSelected(merchant) {
storeRolesAdminLog.info('Merchant selected:', merchant.name);
this.selectedMerchant = merchant;
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
// Destroy previous store selector and reinit with merchant filter
if (this.storeSelector) {
if (typeof this.storeSelector.destroy === 'function') {
this.storeSelector.destroy();
}
this.storeSelector = null;
}
// Wait for DOM update (x-show toggles the store select container)
await this.$nextTick();
this.initStoreSelector(merchant.id);
},
onMerchantCleared() {
storeRolesAdminLog.info('Merchant cleared');
this.selectedMerchant = null;
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
if (this.storeSelector) {
if (typeof this.storeSelector.destroy === 'function') {
this.storeSelector.destroy();
}
this.storeSelector = null;
}
},
// =====================================================================
// Store Selector
// =====================================================================
initStoreSelector(merchantId = null) {
const el = this.$refs.storeSelect;
if (!el) {
storeRolesAdminLog.warn('Store select element not found');
return;
}
const apiEndpoint = merchantId
? `/admin/stores?merchant_id=${merchantId}`
: '/admin/stores';
this.storeSelector = initStoreSelector(el, {
placeholder: merchantId ? 'Select store...' : 'Search store by name or code...',
apiEndpoint: apiEndpoint,
onSelect: async (store) => {
storeRolesAdminLog.info('Store selected:', store);
this.selectedStore = store;
localStorage.setItem('admin_store_roles_selected_store_id', store.id.toString());
await this.loadRoles();
},
onClear: () => {
storeRolesAdminLog.info('Store cleared');
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
}
});
},
// =====================================================================
// Restore / Clear
// =====================================================================
async restoreSavedStore(storeId) {
try {
const store = await apiClient.get(`/admin/stores/${storeId}`);
if (!store) return;
if (isSuperAdmin && store.merchant_id) {
// For super admin, restore the merchant first
try {
const merchant = await apiClient.get(`/admin/merchants/${store.merchant_id}`);
if (merchant && this.merchantSelector) {
this.merchantSelector.addOption({
id: merchant.id,
name: merchant.name,
store_count: merchant.store_count || 0,
});
this.merchantSelector.setValue(merchant.id, true);
this.selectedMerchant = { id: merchant.id, name: merchant.name };
// Wait for DOM, then init store selector and set value
await this.$nextTick();
this.initStoreSelector(merchant.id);
setTimeout(() => {
if (this.storeSelector) {
this.storeSelector.setValue(store.id, store);
}
this.selectedStore = { id: store.id, name: store.name, store_code: store.store_code };
this.loadRoles();
}, 300);
}
} catch (error) {
storeRolesAdminLog.warn('Failed to restore merchant:', error);
localStorage.removeItem('admin_store_roles_selected_store_id');
}
} else {
// Platform admin: just restore the store
if (this.storeSelector) {
this.storeSelector.setValue(store.id, store);
}
this.selectedStore = { id: store.id, name: store.name, store_code: store.store_code };
await this.loadRoles();
}
storeRolesAdminLog.info('Restored store:', store.name);
} catch (error) {
storeRolesAdminLog.warn('Failed to restore saved store:', error);
localStorage.removeItem('admin_store_roles_selected_store_id');
}
},
clearSelection() {
if (isSuperAdmin) {
if (this.merchantSelector) {
this.merchantSelector.clear();
}
this.selectedMerchant = null;
}
if (this.storeSelector) {
if (typeof this.storeSelector.clear === 'function') {
this.storeSelector.clear();
}
}
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
},
// =====================================================================
// Roles CRUD
// =====================================================================
async loadRoles() {
if (!this.selectedStore) return;
this.rolesLoading = true;
try {
const response = await apiClient.get(`/admin/store-roles?store_id=${this.selectedStore.id}`);
this.roles = response.roles || [];
storeRolesAdminLog.info('Loaded', this.roles.length, 'roles');
} catch (error) {
storeRolesAdminLog.error('Failed to load roles:', error);
Utils.showToast(error.message || 'Failed to load roles', 'error');
} finally {
this.rolesLoading = false;
}
},
isPresetRole(name) {
return this.presetRoles.includes(name.toLowerCase());
},
openCreateModal() {
this.editingRole = null;
this.roleForm = { name: '', permissions: [] };
this.showRoleModal = true;
},
openEditModal(role) {
this.editingRole = role;
this.roleForm = {
name: role.name,
permissions: [...(role.permissions || [])],
};
this.showRoleModal = true;
},
togglePermission(permId) {
const idx = this.roleForm.permissions.indexOf(permId);
if (idx >= 0) {
this.roleForm.permissions.splice(idx, 1);
} else {
this.roleForm.permissions.push(permId);
}
},
toggleCategory(category) {
const perms = category.permissions || [];
const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) {
this.roleForm.permissions = this.roleForm.permissions.filter(id => !permIds.includes(id));
} else {
for (const id of permIds) {
if (!this.roleForm.permissions.includes(id)) {
this.roleForm.permissions.push(id);
}
}
}
},
isCategoryFullySelected(category) {
const perms = category.permissions || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
},
async saveRole() {
if (!this.selectedStore) return;
this.saving = true;
try {
const storeParam = `store_id=${this.selectedStore.id}`;
if (this.editingRole) {
await apiClient.put(`/admin/store-roles/${this.editingRole.id}?${storeParam}`, this.roleForm);
} else {
await apiClient.post(`/admin/store-roles?${storeParam}`, this.roleForm);
}
this.showRoleModal = false;
Utils.showToast('Role saved successfully', 'success');
await this.loadRoles();
} catch (error) {
storeRolesAdminLog.error('Error saving role:', error);
Utils.showToast(error.message || 'Failed to save role', 'error');
} finally {
this.saving = false;
}
},
async confirmDelete(role) {
if (!this.selectedStore) return;
if (!confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
try {
await apiClient.delete(`/admin/store-roles/${role.id}?store_id=${this.selectedStore.id}`);
Utils.showToast('Role deleted successfully', 'success');
await this.loadRoles();
} catch (error) {
storeRolesAdminLog.error('Error deleting role:', error);
Utils.showToast(error.message || 'Failed to delete role', 'error');
}
},
};
}
storeRolesAdminLog.info('Module loaded');

View File

@@ -27,7 +27,7 @@ function storeRoles() {
showRoleModal: false,
editingRole: null,
roleForm: { name: '', permissions: [] },
permissionsByCategory: {},
permissionCategories: [],
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() {
@@ -50,40 +50,14 @@ function storeRoles() {
async loadPermissions() {
try {
// Group known permissions by category prefix
const allPerms = window.USER_PERMISSIONS || [];
this.permissionsByCategory = this.groupPermissions(allPerms);
const response = await apiClient.get('/store/team/permissions/catalog');
this.permissionCategories = response.categories || [];
storeRolesLog.info('Loaded permission catalog:', this.permissionCategories.length, 'categories');
} catch (e) {
storeRolesLog.warn('Could not load permission categories:', e);
storeRolesLog.warn('Could not load permission catalog:', e);
}
},
groupPermissions(permIds) {
// Known permission categories from the codebase
const knownPerms = [
'dashboard.view',
'settings.view', 'settings.edit', 'settings.theme', 'settings.domains',
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.import', 'products.export',
'orders.view', 'orders.edit', 'orders.cancel', 'orders.refund',
'customers.view', 'customers.edit', 'customers.delete', 'customers.export',
'stock.view', 'stock.edit', 'stock.transfer',
'team.view', 'team.invite', 'team.edit', 'team.remove',
'analytics.view', 'analytics.export',
'messaging.view_messages', 'messaging.send_messages', 'messaging.manage_templates',
'billing.view_tiers', 'billing.manage_tiers', 'billing.view_subscriptions', 'billing.manage_subscriptions', 'billing.view_invoices',
'cms.view_pages', 'cms.manage_pages', 'cms.view_media', 'cms.manage_media', 'cms.manage_themes',
'loyalty.view_programs', 'loyalty.manage_programs', 'loyalty.view_rewards', 'loyalty.manage_rewards',
'cart.view', 'cart.manage',
];
const groups = {};
for (const perm of knownPerms) {
const cat = perm.split('.')[0];
if (!groups[cat]) groups[cat] = [];
groups[cat].push({ id: perm });
}
return groups;
},
async loadRoles() {
this.loading = true;
this.error = false;
@@ -127,7 +101,7 @@ function storeRoles() {
},
toggleCategory(category) {
const perms = this.permissionsByCategory[category] || [];
const perms = category.permissions || [];
const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) {
@@ -142,7 +116,7 @@ function storeRoles() {
},
isCategoryFullySelected(category) {
const perms = this.permissionsByCategory[category] || [];
const perms = category.permissions || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
},

View File

@@ -0,0 +1,271 @@
{# app/templates/admin/store-roles.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Store Roles{% endblock %}
{% block extra_head %}
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
<style>
.ts-wrapper { width: 100%; }
.ts-control {
background-color: rgb(249 250 251) !important;
border-color: rgb(209 213 219) !important;
border-radius: 0.5rem !important;
padding: 0.5rem 0.75rem !important;
}
.dark .ts-control {
background-color: rgb(55 65 81) !important;
border-color: rgb(75 85 99) !important;
color: rgb(229 231 235) !important;
}
.ts-dropdown {
border-radius: 0.5rem !important;
border-color: rgb(209 213 219) !important;
}
.dark .ts-dropdown {
background-color: rgb(55 65 81) !important;
border-color: rgb(75 85 99) !important;
}
.dark .ts-dropdown .option {
color: rgb(229 231 235) !important;
}
.dark .ts-dropdown .option.active {
background-color: rgb(75 85 99) !important;
}
</style>
{% endblock %}
{% block alpine_data %}adminStoreRoles(){% endblock %}
{% block content %}
{{ page_header('Store Roles', subtitle='Manage roles and permissions for any store') }}
<!-- Selection Panel -->
<div class="px-4 py-6 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
{% if is_super_admin %}
<!-- Super Admin: Merchant → Store cascading -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Merchant
</label>
<select x-ref="merchantSelect" placeholder="Search merchant by name..."></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Store
</label>
<div x-show="!selectedMerchant" class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500 border rounded-lg dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
Select a merchant first
</div>
<div x-show="selectedMerchant" x-cloak>
<select x-ref="storeSelect" placeholder="Select store..."></select>
</div>
</div>
</div>
{% else %}
<!-- Platform Admin: Store only (scoped to their platforms) -->
<div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Store
</label>
<select x-ref="storeSelect" placeholder="Search store by name or code..."></select>
</div>
{% endif %}
</div>
<!-- Selected Store Info -->
<div x-show="selectedStore" x-cloak class="mb-6">
<div class="px-4 py-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('shield-check', 'w-6 h-6 text-purple-600')"></span>
<div>
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Managing Roles For</p>
<p class="text-lg font-semibold text-purple-900 dark:text-purple-100" x-text="selectedStore?.name"></p>
{% if is_super_admin %}
<p class="text-xs text-purple-600 dark:text-purple-400" x-text="selectedMerchant ? 'Merchant: ' + selectedMerchant.name : ''"></p>
{% endif %}
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="openCreateModal()"
class="flex items-center px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Create Role
</button>
<button
@click="clearSelection()"
class="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-700 dark:text-purple-300 bg-purple-100 dark:bg-purple-800 rounded-md hover:bg-purple-200 dark:hover:bg-purple-700 transition-colors"
>
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
Clear
</button>
</div>
</div>
</div>
</div>
<!-- Loading -->
<div x-show="rolesLoading" class="text-center py-12">
<div class="inline-block animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full"></div>
<p class="mt-4 text-gray-500 dark:text-gray-400">Loading roles...</p>
</div>
<!-- Roles List -->
<div x-show="selectedStore && !rolesLoading" class="space-y-6">
<template x-for="role in roles" :key="role.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="role.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="(role.permissions || []).length"></span> permissions
<template x-if="isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full dark:bg-blue-900 dark:text-blue-200">Preset</span>
</template>
<template x-if="!isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full dark:bg-green-900 dark:text-green-200">Custom</span>
</template>
</p>
</div>
<div class="flex items-center gap-2">
<button
@click="openEditModal(role)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-400 dark:bg-purple-900/20 dark:hover:bg-purple-900/40"
>
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
Edit
</button>
<button
x-show="!isPresetRole(role.name)"
@click="confirmDelete(role)"
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40"
>
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete
</button>
</div>
</div>
<!-- Permission tags -->
<div class="flex flex-wrap gap-1.5">
<template x-for="perm in (role.permissions || [])" :key="perm">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300" x-text="perm"></span>
</template>
<template x-if="!role.permissions || role.permissions.length === 0">
<span class="text-sm text-gray-400 dark:text-gray-500">No permissions assigned</span>
</template>
</div>
</div>
</template>
<template x-if="roles.length === 0 && !rolesLoading">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield', 'w-12 h-12 mx-auto mb-4 opacity-50')"></span>
<p>No roles found for this store.</p>
</div>
</template>
</div>
<!-- No Store Selected -->
<div x-show="!selectedStore && !rolesLoading" class="text-center py-12">
<span x-html="$icon('shield-check', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
{% if is_super_admin %}
<p class="text-gray-600 dark:text-gray-400">Select a merchant and store above to manage roles</p>
{% else %}
<p class="text-gray-600 dark:text-gray-400">Select a store above to manage its roles</p>
{% endif %}
</div>
<!-- Create/Edit Role Modal -->
{% call modal_simple('roleModal', 'editingRole ? "Edit Role" : "Create Role"', 'showRoleModal') %}
<div class="space-y-4">
<!-- Role Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role Name</label>
<input
type="text"
x-model="roleForm.name"
placeholder="e.g. Content Editor"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<!-- Permission Matrix -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
<template x-for="category in permissionCategories" :key="category.id">
<div class="border-b last:border-b-0 dark:border-gray-600">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category.label"></span>
<button
@click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="isCategoryFullySelected(category) ? 'Deselect All' : 'Select All'"
></button>
</div>
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
<template x-for="perm in category.permissions" :key="perm.id">
<label class="flex items-start gap-2 py-1.5 cursor-pointer">
<input
type="checkbox"
:value="perm.id"
:checked="roleForm.permissions.includes(perm.id)"
@change="togglePermission(perm.id)"
class="w-4 h-4 mt-0.5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<div class="flex flex-col">
<span class="flex items-center gap-1">
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="perm.label"></span>
<span
x-show="perm.description"
:title="perm.description"
x-html="$icon('information-circle', 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 cursor-help')"
></span>
</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono" x-text="perm.id"></span>
</div>
<template x-if="perm.is_owner_only">
<span class="ml-auto px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded dark:bg-amber-900/30 dark:text-amber-400">Owner</span>
</template>
</label>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button
@click="showRoleModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:text-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>Cancel</button>
<button
@click="saveRole()"
:disabled="saving || !roleForm.name.trim()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="saving" class="inline-block animate-spin mr-1">&#8635;</span>
<span x-text="editingRole ? 'Update Role' : 'Create Role'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script>
window._adminStoreRolesConfig = { isSuperAdmin: {{ is_super_admin | tojson }} };
</script>
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-roles.js') }}"></script>
{% endblock %}

View File

@@ -101,10 +101,10 @@
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
<template x-for="(perms, category) in permissionsByCategory" :key="category">
<template x-for="category in permissionCategories" :key="category.id">
<div class="border-b last:border-b-0 dark:border-gray-600">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category"></span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category.label"></span>
<button
@click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
@@ -112,16 +112,29 @@
></button>
</div>
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
<template x-for="perm in perms" :key="perm.id">
<label class="flex items-center gap-2 py-1 cursor-pointer">
<template x-for="perm in category.permissions" :key="perm.id">
<label class="flex items-start gap-2 py-1.5 cursor-pointer">
<input
type="checkbox"
:value="perm.id"
:checked="roleForm.permissions.includes(perm.id)"
@change="togglePermission(perm.id)"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
class="w-4 h-4 mt-0.5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="perm.id"></span>
<div class="flex flex-col">
<span class="flex items-center gap-1">
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="perm.label"></span>
<span
x-show="perm.description"
:title="perm.description"
x-html="$icon('information-circle', 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 cursor-help')"
></span>
</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono" x-text="perm.id"></span>
</div>
<template x-if="perm.is_owner_only">
<span class="ml-auto px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded dark:bg-amber-900/30 dark:text-amber-400">Owner</span>
</template>
</label>
</template>
</div>

View File

@@ -0,0 +1,276 @@
# app/modules/tenancy/tests/integration/test_admin_store_roles_api.py
"""
Integration tests for admin store role management API endpoints.
Tests the admin role management endpoints at:
/api/v1/admin/store-roles
Authentication: Uses super_admin_headers and platform_admin_headers
fixtures from tests/fixtures/auth_fixtures.py.
"""
import uuid
import pytest
from app.modules.tenancy.models import (
Merchant,
Platform,
Role,
Store,
StorePlatform,
User,
)
from middleware.auth import AuthManager
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/admin/store-roles"
@pytest.fixture
def admin_store_merchant(db):
"""Create a merchant for admin role tests."""
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"admin_role_owner_{uid}@test.com",
username=f"admin_role_owner_{uid}",
hashed_password=auth.hash_password("ownerpass123"),
role="merchant_owner",
is_active=True,
)
db.add(owner)
db.flush()
merchant = Merchant(
name="Admin Role Test Merchant",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def admin_role_store(db, admin_store_merchant):
"""Create a store for admin role tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=admin_store_merchant.id,
store_code=f"ADMROLE_{uid.upper()}",
subdomain=f"admrole{uid}",
name=f"Admin Role Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def admin_role_custom(db, admin_role_store):
"""Create a custom role for update/delete tests."""
role = Role(
store_id=admin_role_store.id,
name="admin_test_custom_role",
permissions=["products.view", "orders.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def test_platform(db):
"""Create a platform for scoping tests."""
uid = uuid.uuid4().hex[:8]
platform = Platform(
code=f"test_{uid}",
name=f"Test Platform {uid}",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def store_on_platform(db, admin_role_store, test_platform):
"""Link the test store to a platform."""
sp = StorePlatform(
store_id=admin_role_store.id,
platform_id=test_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
# ============================================================================
# Super Admin Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminListStoreRoles:
"""Tests for GET /api/v1/admin/store-roles."""
def test_list_roles_as_super_admin(
self, client, super_admin_headers, admin_role_store
):
"""Super admin can list roles for any store."""
response = client.get(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "roles" in data
assert "total" in data
# Default preset roles should be created
assert data["total"] >= 5
def test_list_roles_requires_store_id(self, client, super_admin_headers):
"""GET /store-roles without store_id returns 422."""
response = client.get(BASE, headers=super_admin_headers)
assert response.status_code == 422
def test_list_roles_unauthenticated(self, client, admin_role_store):
"""Unauthenticated request is rejected."""
response = client.get(f"{BASE}?store_id={admin_role_store.id}")
assert response.status_code in (401, 403)
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminCreateStoreRole:
"""Tests for POST /api/v1/admin/store-roles."""
def test_create_role_as_super_admin(
self, client, super_admin_headers, admin_role_store
):
"""Super admin can create a custom role for any store."""
response = client.post(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "admin_created_role",
"permissions": ["products.view", "orders.view"],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "admin_created_role"
assert "products.view" in data["permissions"]
def test_create_preset_name_rejected(
self, client, super_admin_headers, admin_role_store
):
"""Cannot create a role with a preset name."""
response = client.post(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "manager",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminUpdateStoreRole:
"""Tests for PUT /api/v1/admin/store-roles/{role_id}."""
def test_update_role_as_super_admin(
self, client, super_admin_headers, admin_role_store, admin_role_custom
):
"""Super admin can update a custom role."""
response = client.put(
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "renamed_admin_role",
"permissions": ["products.view", "products.edit"],
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "renamed_admin_role"
assert "products.edit" in data["permissions"]
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminDeleteStoreRole:
"""Tests for DELETE /api/v1/admin/store-roles/{role_id}."""
def test_delete_role_as_super_admin(
self, client, super_admin_headers, admin_role_store, admin_role_custom
):
"""Super admin can delete a custom role."""
response = client.delete(
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 204
def test_delete_nonexistent_role(
self, client, super_admin_headers, admin_role_store
):
"""Deleting nonexistent role returns 422."""
response = client.delete(
f"{BASE}/99999?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 422
# ============================================================================
# Permission Catalog Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminPermissionCatalog:
"""Tests for GET /api/v1/admin/store-roles/permissions/catalog."""
def test_catalog_returns_categories(self, client, super_admin_headers):
"""GET /permissions/catalog returns categories with permissions."""
response = client.get(
f"{BASE}/permissions/catalog",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert len(data["categories"]) > 0
def test_catalog_permission_has_metadata(self, client, super_admin_headers):
"""Each permission has id, label, description, and is_owner_only."""
response = client.get(
f"{BASE}/permissions/catalog",
headers=super_admin_headers,
)
data = response.json()
perm = data["categories"][0]["permissions"][0]
assert "id" in perm
assert "label" in perm
assert "description" in perm
assert "is_owner_only" in perm

View File

@@ -353,3 +353,54 @@ class TestDeleteRole:
)
assert response.status_code == 422
assert "preset" in response.json()["message"].lower()
# ============================================================================
# GET /team/permissions/catalog
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestPermissionCatalog:
"""Tests for GET /api/v1/store/team/permissions/catalog."""
def test_catalog_returns_categories(self, client, role_auth):
"""GET /permissions/catalog returns categories with permissions."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert len(data["categories"]) > 0
def test_catalog_category_has_permissions(self, client, role_auth):
"""Each category contains permission items."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
for category in data["categories"]:
assert "id" in category
assert "label" in category
assert "permissions" in category
assert len(category["permissions"]) > 0
def test_catalog_permission_has_metadata(self, client, role_auth):
"""Each permission has id, label, description, and is_owner_only."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
perm = data["categories"][0]["permissions"][0]
assert "id" in perm
assert "label" in perm
assert "description" in perm
assert "is_owner_only" in perm
def test_catalog_includes_team_permissions(self, client, role_auth):
"""Catalog includes team permissions from tenancy module."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
all_perm_ids = {
p["id"]
for cat in data["categories"]
for p in cat["permissions"]
}
assert "team.view" in all_perm_ids
assert "team.edit" in all_perm_ids

View File

@@ -12,7 +12,14 @@ from app.modules.tenancy.exceptions import (
InvalidRoleException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, User
from app.modules.tenancy.models import (
Platform,
Role,
Store,
StorePlatform,
StoreUser,
User,
)
from app.modules.tenancy.services.store_team_service import store_team_service
# =============================================================================
@@ -728,3 +735,80 @@ class TestStoreTeamServiceDeleteRole:
store_id=team_store.id,
role_id=role.id,
)
# =============================================================================
# ADMIN STORE ACCESS VALIDATION
# =============================================================================
@pytest.mark.unit
@pytest.mark.tenancy
class TestValidateAdminStoreAccess:
"""Tests for validate_admin_store_access()."""
def test_super_admin_can_access_any_store(self, db, team_store):
"""Super admin (accessible_platform_ids=None) can access any store."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = None
store = store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
assert store.id == team_store.id
def test_platform_admin_can_access_store_in_their_platform(self, db, team_store):
"""Platform admin can access stores in their assigned platform."""
from unittest.mock import MagicMock
# Create a platform and link the store
platform = Platform(
code=f"test_plat_{uuid.uuid4().hex[:6]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.flush()
sp = StorePlatform(
store_id=team_store.id,
platform_id=platform.id,
is_active=True,
)
db.add(sp)
db.flush()
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = [platform.id]
store = store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
assert store.id == team_store.id
def test_platform_admin_cannot_access_store_outside_platform(self, db, team_store):
"""Platform admin cannot access stores outside their platform."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
# Platform ID 99999 does not have the test store
user_ctx.get_accessible_platform_ids.return_value = [99999]
with pytest.raises(InvalidRoleException, match="do not have access"):
store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
def test_nonexistent_store_raises_error(self, db):
"""Accessing a nonexistent store raises InvalidRoleException."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = None
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.validate_admin_store_access(
db, user_ctx, 99999
)