feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
Some checks failed
- 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:
419
app/modules/tenancy/static/admin/js/store-roles.js
Normal file
419
app/modules/tenancy/static/admin/js/store-roles.js
Normal 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');
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user