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>
420 lines
16 KiB
JavaScript
420 lines
16 KiB
JavaScript
// 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');
|