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

@@ -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));
},