window.app = Vue.createApp({ mixins: [windowMixin], data() { return { permissions: [], accounts: [], users: [], filteredUsers: [], equityEligibleUsers: [], loading: false, granting: false, revoking: false, grantingEquity: false, revokingEquity: false, activeTab: 'by-user', showGrantDialog: false, showRevokeDialog: false, showGrantEquityDialog: false, showRevokeEquityDialog: false, showBulkGrantDialog: false, showBulkGrantErrors: false, permissionToRevoke: null, equityToRevoke: null, bulkGranting: false, bulkGrantResults: null, isSuperUser: false, grantForm: { user_id: '', account_id: '', permission_type: 'submit_expense', notes: '', expires_at: '' }, grantEquityForm: { user_id: '', notes: '' }, bulkGrantForm: { user_ids: [], account_id: '', permission_type: 'submit_expense', notes: '', expires_at: '' }, permissionTypeOptions: [ { value: 'read', label: 'Read', description: 'View account and balance' }, { value: 'submit_expense', label: 'Submit Expense', description: 'Submit expenses to this account' }, { value: 'manage', label: 'Manage', description: 'Full account management' } ], // RBAC-related data roles: [], selectedRole: null, roleToDelete: null, editingRole: false, showCreateRoleDialog: false, showViewRoleDialog: false, showDeleteRoleDialog: false, showAssignRoleDialog: false, showRevokeUserRoleDialog: false, savingRole: false, deletingRole: false, assigningRole: false, revokingUserRole: false, userRoleToRevoke: null, roleForm: { name: '', description: '', is_default: false }, assignRoleForm: { user_id: '', role_id: '', expires_at: '', notes: '' }, roleUsersForView: [], rolePermissionsForView: [], userRoles: new Map(), // Map of user_id -> array of roles showAddRolePermissionDialog: false, rolePermissionForm: { account_id: '', permission_type: '', notes: '' } } }, computed: { accountOptions() { return this.accounts.map(acc => ({ id: acc.id, name: acc.name, is_virtual: acc.is_virtual || false })) }, userOptions() { const users = this.filteredUsers.length > 0 ? this.filteredUsers : this.users return users.map(user => ({ id: user.id, username: user.username || user.id, label: user.username ? `${user.username} (${user.id.substring(0, 8)}...)` : user.id })) }, isGrantFormValid() { return !!( this.grantForm.user_id && this.grantForm.account_id && this.grantForm.permission_type ) }, isBulkGrantFormValid() { return !!( this.bulkGrantForm.user_ids && this.bulkGrantForm.user_ids.length > 0 && this.bulkGrantForm.account_id && this.bulkGrantForm.permission_type ) }, permissionsByUser() { const grouped = new Map() for (const perm of this.permissions) { if (!grouped.has(perm.user_id)) { grouped.set(perm.user_id, []) } grouped.get(perm.user_id).push(perm) } return grouped }, // Get all unique user IDs from both direct permissions and role assignments allUserIds() { const userIds = new Set() // Add users with direct permissions for (const userId of this.permissionsByUser.keys()) { userIds.add(userId) } // Add users with role assignments for (const userId of this.userRoles.keys()) { userIds.add(userId) } return Array.from(userIds).sort() }, permissionsByAccount() { const grouped = new Map() for (const perm of this.permissions) { if (!grouped.has(perm.account_id)) { grouped.set(perm.account_id, []) } grouped.get(perm.account_id).push(perm) } return grouped }, roleOptions() { return this.roles.map(role => ({ value: role.id, label: role.name, description: role.description, is_default: role.is_default })) }, accountOptions() { return this.accounts.map(account => ({ value: account.id, label: account.name, name: account.name, description: account.account_type, is_virtual: account.is_virtual })) } }, methods: { async loadPermissions() { if (!this.isSuperUser) { this.$q.notify({ type: 'warning', message: 'Admin access required to view permissions', timeout: 3000 }) return } this.loading = true try { const response = await LNbits.api.request( 'GET', '/castle/api/v1/admin/permissions', this.g.user.wallets[0].adminkey ) this.permissions = response.data } catch (error) { console.error('Failed to load permissions:', error) this.$q.notify({ type: 'negative', message: 'Failed to load permissions', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.loading = false } }, async loadAccounts() { try { // Admin permissions UI needs to see virtual accounts to grant permissions on them const response = await LNbits.api.request( 'GET', '/castle/api/v1/accounts?exclude_virtual=false', this.g.user.wallets[0].inkey ) this.accounts = response.data } catch (error) { console.error('Failed to load accounts:', error) this.$q.notify({ type: 'negative', message: 'Failed to load accounts', caption: error.message || 'Unknown error', timeout: 5000 }) } }, async loadUsers() { if (!this.isSuperUser) { return } try { const response = await LNbits.api.request( 'GET', '/castle/api/v1/admin/castle-users', this.g.user.wallets[0].adminkey ) this.users = response.data || [] this.filteredUsers = [] } catch (error) { console.error('Failed to load users:', error) this.$q.notify({ type: 'negative', message: 'Failed to load users', caption: error.message || 'Unknown error', timeout: 5000 }) } }, filterUsers(val, update) { if (val === '') { update(() => { this.filteredUsers = [] }) return } update(() => { const needle = val.toLowerCase() this.filteredUsers = this.users.filter(user => { const username = user.username || '' const userId = user.id || '' return username.toLowerCase().includes(needle) || userId.toLowerCase().includes(needle) }) }) }, async grantPermission() { if (!this.isGrantFormValid) { this.$q.notify({ type: 'warning', message: 'Please fill in all required fields', timeout: 3000 }) return } this.granting = true try { // Extract account_id - handle both string and object cases const accountId = typeof this.grantForm.account_id === 'object' ? (this.grantForm.account_id.value || this.grantForm.account_id.id) : this.grantForm.account_id const payload = { user_id: this.grantForm.user_id, account_id: accountId, permission_type: this.grantForm.permission_type } if (this.grantForm.notes) { payload.notes = this.grantForm.notes } if (this.grantForm.expires_at) { payload.expires_at = new Date(this.grantForm.expires_at).toISOString() } await LNbits.api.request( 'POST', '/castle/api/v1/admin/permissions', this.g.user.wallets[0].adminkey, payload ) this.$q.notify({ type: 'positive', message: 'Permission granted successfully', timeout: 3000 }) this.showGrantDialog = false this.resetGrantForm() await this.loadPermissions() } catch (error) { console.error('Failed to grant permission:', error) this.$q.notify({ type: 'negative', message: 'Failed to grant permission', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.granting = false } }, confirmRevokePermission(permission) { this.permissionToRevoke = permission this.showRevokeDialog = true }, async revokePermission() { if (!this.permissionToRevoke) return this.revoking = true try { await LNbits.api.request( 'DELETE', `/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`, this.g.user.wallets[0].adminkey ) this.$q.notify({ type: 'positive', message: 'Permission revoked successfully', timeout: 3000 }) this.showRevokeDialog = false this.permissionToRevoke = null await this.loadPermissions() } catch (error) { console.error('Failed to revoke permission:', error) this.$q.notify({ type: 'negative', message: 'Failed to revoke permission', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.revoking = false } }, resetGrantForm() { this.grantForm = { user_id: '', account_id: '', permission_type: 'submit_expense', notes: '', expires_at: '' } }, async bulkGrantPermissions() { if (!this.isBulkGrantFormValid) { this.$q.notify({ type: 'warning', message: 'Please fill in all required fields', timeout: 3000 }) return } this.bulkGranting = true this.bulkGrantResults = null try { // Extract account_id - handle both string and object cases const accountId = typeof this.bulkGrantForm.account_id === 'object' ? (this.bulkGrantForm.account_id.value || this.bulkGrantForm.account_id.id) : this.bulkGrantForm.account_id const payload = { user_ids: this.bulkGrantForm.user_ids, account_id: accountId, permission_type: this.bulkGrantForm.permission_type } if (this.bulkGrantForm.notes) { payload.notes = this.bulkGrantForm.notes } if (this.bulkGrantForm.expires_at) { payload.expires_at = new Date(this.bulkGrantForm.expires_at).toISOString() } const response = await LNbits.api.request( 'POST', '/castle/api/v1/admin/permissions/bulk-grant', this.g.user.wallets[0].adminkey, payload ) this.bulkGrantResults = response.data // Show success notification const message = this.bulkGrantResults.failure_count > 0 ? `Bulk grant completed: ${this.bulkGrantResults.success_count} succeeded, ${this.bulkGrantResults.failure_count} failed` : `Successfully granted permissions to ${this.bulkGrantResults.success_count} users` this.$q.notify({ type: this.bulkGrantResults.failure_count > 0 ? 'warning' : 'positive', message: message, timeout: 5000 }) // Reload permissions to show new grants await this.loadPermissions() // Don't close dialog immediately if there were failures // (so user can review errors) if (this.bulkGrantResults.failure_count === 0) { setTimeout(() => { this.closeBulkGrantDialog() }, 2000) } } catch (error) { console.error('Failed to bulk grant permissions:', error) this.$q.notify({ type: 'negative', message: 'Failed to bulk grant permissions', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.bulkGranting = false } }, closeBulkGrantDialog() { this.showBulkGrantDialog = false this.resetBulkGrantForm() this.bulkGrantResults = null }, resetBulkGrantForm() { this.bulkGrantForm = { user_ids: [], account_id: '', permission_type: 'submit_expense', notes: '', expires_at: '' } }, getAccountName(accountId) { const account = this.accounts.find(a => a.id === accountId) return account ? account.name : accountId }, getPermissionLabel(permissionType) { const option = this.permissionTypeOptions.find(opt => opt.value === permissionType) return option ? option.label : permissionType }, getPermissionColor(permissionType) { switch (permissionType) { case 'read': return 'blue' case 'submit_expense': return 'green' case 'manage': return 'red' default: return 'grey' } }, getPermissionIcon(permissionType) { switch (permissionType) { case 'read': return 'visibility' case 'submit_expense': return 'add_circle' case 'manage': return 'admin_panel_settings' default: return 'security' } }, formatDate(dateString) { if (!dateString) return '-' const date = new Date(dateString) return date.toLocaleString() }, async loadEquityEligibleUsers() { if (!this.isSuperUser) { return } try { const response = await LNbits.api.request( 'GET', '/castle/api/v1/admin/equity-eligibility', this.g.user.wallets[0].adminkey ) this.equityEligibleUsers = response.data || [] } catch (error) { console.error('Failed to load equity-eligible users:', error) this.$q.notify({ type: 'negative', message: 'Failed to load equity-eligible users', caption: error.message || 'Unknown error', timeout: 5000 }) } }, async grantEquityEligibility() { if (!this.grantEquityForm.user_id) { this.$q.notify({ type: 'warning', message: 'Please select a user', timeout: 3000 }) return } this.grantingEquity = true try { const payload = { user_id: this.grantEquityForm.user_id, is_equity_eligible: true } if (this.grantEquityForm.notes) { payload.notes = this.grantEquityForm.notes } await LNbits.api.request( 'POST', '/castle/api/v1/admin/equity-eligibility', this.g.user.wallets[0].adminkey, payload ) this.$q.notify({ type: 'positive', message: 'Equity eligibility granted successfully', timeout: 3000 }) this.showGrantEquityDialog = false this.resetGrantEquityForm() await this.loadEquityEligibleUsers() } catch (error) { console.error('Failed to grant equity eligibility:', error) this.$q.notify({ type: 'negative', message: 'Failed to grant equity eligibility', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.grantingEquity = false } }, confirmRevokeEquity(equity) { this.equityToRevoke = equity this.showRevokeEquityDialog = true }, async revokeEquityEligibility() { if (!this.equityToRevoke) return this.revokingEquity = true try { await LNbits.api.request( 'DELETE', `/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`, this.g.user.wallets[0].adminkey ) this.$q.notify({ type: 'positive', message: 'Equity eligibility revoked successfully', timeout: 3000 }) this.showRevokeEquityDialog = false this.equityToRevoke = null await this.loadEquityEligibleUsers() } catch (error) { console.error('Failed to revoke equity eligibility:', error) this.$q.notify({ type: 'negative', message: 'Failed to revoke equity eligibility', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.revokingEquity = false } }, resetGrantEquityForm() { this.grantEquityForm = { user_id: '', notes: '' } }, // ===== RBAC ROLE MANAGEMENT METHODS ===== async loadRoles() { if (!this.isSuperUser) { return } try { const response = await LNbits.api.request( 'GET', '/castle/api/v1/admin/roles', this.g.user.wallets[0].adminkey ) this.roles = response.data || [] } catch (error) { console.error('Failed to load roles:', error) this.$q.notify({ type: 'negative', message: 'Failed to load roles', caption: error.message || 'Unknown error', timeout: 5000 }) } }, async viewRole(role) { this.selectedRole = role this.roleUsersForView = [] this.rolePermissionsForView = [] try { const response = await LNbits.api.request( 'GET', `/castle/api/v1/admin/roles/${role.id}`, this.g.user.wallets[0].adminkey ) // Create fresh arrays to ensure Vue reactivity works properly this.rolePermissionsForView = [...(response.data.permissions || [])] this.roleUsersForView = [...(response.data.users || [])] // Wait for Vue to update the DOM before showing dialog await this.$nextTick() this.showViewRoleDialog = true } catch (error) { console.error('Failed to load role details:', error) this.$q.notify({ type: 'negative', message: 'Failed to load role details', caption: error.message || 'Unknown error', timeout: 5000 }) } }, editRole(role) { this.editingRole = true this.selectedRole = role this.roleForm = { name: role.name, description: role.description || '', is_default: role.is_default || false } this.showCreateRoleDialog = true }, async saveRole() { if (!this.roleForm.name) { this.$q.notify({ type: 'warning', message: 'Please enter a role name', timeout: 3000 }) return } this.savingRole = true try { const payload = { name: this.roleForm.name, description: this.roleForm.description || null, is_default: this.roleForm.is_default || false } if (this.editingRole) { // Update existing role await LNbits.api.request( 'PUT', `/castle/api/v1/admin/roles/${this.selectedRole.id}`, this.g.user.wallets[0].adminkey, payload ) this.$q.notify({ type: 'positive', message: 'Role updated successfully', timeout: 3000 }) } else { // Create new role await LNbits.api.request( 'POST', '/castle/api/v1/admin/roles', this.g.user.wallets[0].adminkey, payload ) this.$q.notify({ type: 'positive', message: 'Role created successfully', timeout: 3000 }) } this.closeRoleDialog() await this.loadRoles() } catch (error) { console.error('Failed to save role:', error) this.$q.notify({ type: 'negative', message: `Failed to ${this.editingRole ? 'update' : 'create'} role`, caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.savingRole = false } }, confirmDeleteRole(role) { this.roleToDelete = role this.showDeleteRoleDialog = true }, async deleteRole() { if (!this.roleToDelete) return this.deletingRole = true try { await LNbits.api.request( 'DELETE', `/castle/api/v1/admin/roles/${this.roleToDelete.id}`, this.g.user.wallets[0].adminkey ) this.$q.notify({ type: 'positive', message: 'Role deleted successfully', timeout: 3000 }) this.closeDeleteRoleDialog() await this.loadRoles() } catch (error) { console.error('Failed to delete role:', error) this.$q.notify({ type: 'negative', message: 'Failed to delete role', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.deletingRole = false } }, closeRoleDialog() { this.showCreateRoleDialog = false this.editingRole = false this.selectedRole = null this.resetRoleForm() }, closeViewRoleDialog() { this.showViewRoleDialog = false this.selectedRole = null this.roleUsersForView = [] this.rolePermissionsForView = [] }, closeDeleteRoleDialog() { this.showDeleteRoleDialog = false this.roleToDelete = null }, closeAssignRoleDialog() { this.showAssignRoleDialog = false this.resetAssignRoleForm() }, async assignRole() { if (!this.assignRoleForm.user_id || !this.assignRoleForm.role_id) { this.$q.notify({ type: 'warning', message: 'Please select both a user and a role', timeout: 3000 }) return } this.assigningRole = true try { const payload = { user_id: this.assignRoleForm.user_id, role_id: this.assignRoleForm.role_id } if (this.assignRoleForm.notes) { payload.notes = this.assignRoleForm.notes } if (this.assignRoleForm.expires_at) { payload.expires_at = new Date(this.assignRoleForm.expires_at).toISOString() } await LNbits.api.request( 'POST', '/castle/api/v1/admin/user-roles', this.g.user.wallets[0].adminkey, payload ) this.$q.notify({ type: 'positive', message: 'Role assigned successfully', timeout: 3000 }) this.closeAssignRoleDialog() await this.loadRoles() } catch (error) { console.error('Failed to assign role:', error) this.$q.notify({ type: 'negative', message: 'Failed to assign role', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.assigningRole = false } }, resetRoleForm() { this.roleForm = { name: '', description: '', is_default: false } }, resetAssignRoleForm() { this.assignRoleForm = { user_id: '', role_id: '', expires_at: '', notes: '' } }, // Get roles for a specific user getUserRoles(userId) { const userRoleAssignments = this.userRoles.get(userId) || [] // Map role assignments to role objects return userRoleAssignments .map(ur => this.roles.find(r => r.id === ur.role_id)) .filter(r => r) // Filter out null/undefined }, // Load all user role assignments async loadUserRoles() { if (!this.isSuperUser) return try { const response = await LNbits.api.request( 'GET', '/castle/api/v1/admin/users/roles', this.g.user.wallets[0].adminkey ) // Group by user_id this.userRoles.clear() if (response.data && Array.isArray(response.data)) { response.data.forEach(userRole => { if (!this.userRoles.has(userRole.user_id)) { this.userRoles.set(userRole.user_id, []) } this.userRoles.get(userRole.user_id).push(userRole) }) } } catch (error) { console.error('Failed to load user roles:', error) this.$q.notify({ type: 'negative', message: 'Failed to load user role assignments', caption: error.message || 'Unknown error', timeout: 5000 }) } }, // Get user role assignments (returns UserRole objects, not Role objects) getUserRoleAssignments(userId) { return this.userRoles.get(userId) || [] }, // Get role name by ID getRoleName(roleId) { const role = this.roles.find(r => r.id === roleId) return role ? role.name : 'Unknown Role' }, // View role by ID viewRoleById(roleId) { const role = this.roles.find(r => r.id === roleId) if (role) { this.viewRole(role) } }, // Show assign role dialog with user pre-selected showAssignRoleForUser(userId) { this.assignRoleForm.user_id = userId this.showAssignRoleDialog = true }, // Show confirmation dialog for revoking user role confirmRevokeUserRole(userRole) { this.userRoleToRevoke = userRole this.showRevokeUserRoleDialog = true }, // Revoke user role async revokeUserRole() { if (!this.userRoleToRevoke) return this.revokingUserRole = true try { await LNbits.api.request( 'DELETE', `/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`, this.g.user.wallets[0].adminkey ) this.$q.notify({ type: 'positive', message: 'Role revoked successfully', timeout: 3000 }) // Reload data await this.loadUserRoles() await this.loadRoles() // Close dialog this.showRevokeUserRoleDialog = false this.userRoleToRevoke = null } catch (error) { console.error('Failed to revoke role:', error) this.$q.notify({ type: 'negative', message: 'Failed to revoke role', caption: error.message || 'Unknown error', timeout: 5000 }) } finally { this.revokingUserRole = false } }, // Add permission to role async addRolePermission() { if (!this.selectedRole || !this.rolePermissionForm.account_id || !this.rolePermissionForm.permission_type) { return } try { // Extract account_id - handle both string and object cases const accountId = typeof this.rolePermissionForm.account_id === 'object' ? (this.rolePermissionForm.account_id.value || this.rolePermissionForm.account_id.id) : this.rolePermissionForm.account_id const payload = { role_id: this.selectedRole.id, account_id: accountId, permission_type: this.rolePermissionForm.permission_type, notes: this.rolePermissionForm.notes || null } await LNbits.api.request( 'POST', `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`, this.g.user.wallets[0].adminkey, payload ) this.closeAddRolePermissionDialog() // Reload role permissions await this.viewRole(this.selectedRole) this.$q.notify({ type: 'positive', message: 'Permission added to role successfully', timeout: 3000 }) } catch (error) { console.error('Failed to add permission to role:', error) this.$q.notify({ type: 'negative', message: 'Failed to add permission to role', caption: error.message || 'Unknown error', timeout: 5000 }) } }, // Delete role permission async deleteRolePermission(permissionId) { this.$q.dialog({ title: 'Confirm', message: 'Are you sure you want to remove this permission from the role?', cancel: true, persistent: true }).onOk(async () => { try { await LNbits.api.request( 'DELETE', `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`, this.g.user.wallets[0].adminkey ) // Reload role permissions await this.viewRole(this.selectedRole) this.$q.notify({ type: 'positive', message: 'Permission removed from role', timeout: 3000 }) } catch (error) { console.error('Failed to delete role permission:', error) this.$q.notify({ type: 'negative', message: 'Failed to remove permission', caption: error.message || 'Unknown error', timeout: 5000 }) } }) }, // Close add role permission dialog closeAddRolePermissionDialog() { this.showAddRolePermissionDialog = false this.rolePermissionForm = { account_id: '', permission_type: '', notes: '' } } }, async created() { // Check if user is super user this.isSuperUser = this.g.user.super_user || false if (this.g.user.wallets && this.g.user.wallets.length > 0) { await this.loadAccounts() if (this.isSuperUser) { await Promise.all([ this.loadPermissions(), this.loadUsers(), this.loadEquityEligibleUsers(), this.loadRoles(), this.loadUserRoles() ]) } } } }) window.app.mount('#vue')