From f2df2f543bc04b128fd58e6ae04dc2e9365d28ea Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 13 Nov 2025 10:17:28 +0100 Subject: [PATCH] Enhance RBAC user management UI and fix permission checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add role management to "By User" tab - Show all users with roles and/or direct permissions - Add ability to assign/revoke roles from users - Display role chips as clickable and removable - Add "Assign Role" button for each user - Fix account_id validation error in permission granting - Extract account_id string from Quasar q-select object - Apply fix to grantPermission, bulkGrantPermissions, and addRolePermission - Fix role-based permission checking for expense submission - Update get_user_permissions_with_inheritance() to include role permissions - Ensures users with role-based permissions can submit expenses - Improve Vue reactivity for role details dialog - Use spread operator to create fresh arrays - Add $nextTick() before showing dialog 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crud.py | 17 +- static/js/index.js | 1 - static/js/permissions.js | 546 +++++++++++++++++++++++- templates/castle/permissions.html | 660 +++++++++++++++++++++++++++++- 4 files changed, 1207 insertions(+), 17 deletions(-) diff --git a/crud.py b/crud.py index e4aeb5e..0976e72 100644 --- a/crud.py +++ b/crud.py @@ -1188,6 +1188,7 @@ async def get_user_permissions_with_inheritance( ) -> list[tuple["AccountPermission", Optional[str]]]: """ Get all permissions for a user on an account, including inherited permissions from parent accounts. + Includes both direct permissions AND role-based permissions. Returns list of tuples: (permission, parent_account_name or None) Example: @@ -1196,13 +1197,23 @@ async def get_user_permissions_with_inheritance( """ from .models import AccountPermission, PermissionType - # Get all user's permissions of this type - user_permissions = await get_user_permissions(user_id, permission_type) + # Get direct user permissions of this type + direct_permissions = await get_user_permissions(user_id, permission_type) + + # Get role-based permissions of this type + role_permissions_list = await get_user_permissions_from_roles(user_id) + role_perms = [] + for role, perms in role_permissions_list: + # Filter for the specific permission type + role_perms.extend([p for p in perms if p.permission_type == permission_type]) + + # Combine direct and role-based permissions + all_permissions = list(direct_permissions) + role_perms # Find which permissions apply to this account (direct or inherited) applicable_permissions = [] - for perm in user_permissions: + for perm in all_permissions: # Get the account for this permission account = await get_account(perm.account_id) if not account: diff --git a/static/js/index.js b/static/js/index.js index d449a34..5a1b767 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -3,7 +3,6 @@ const mapJournalEntry = obj => { } window.app = Vue.createApp({ - el: '#vue', mixins: [windowMixin], data() { return { diff --git a/static/js/permissions.js b/static/js/permissions.js index dcacb87..0de3569 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -1,5 +1,4 @@ window.app = Vue.createApp({ - el: '#vue', mixins: [windowMixin], data() { return { @@ -59,7 +58,42 @@ window.app = Vue.createApp({ 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: '' + } } }, @@ -109,6 +143,23 @@ window.app = Vue.createApp({ 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) { @@ -118,6 +169,25 @@ window.app = Vue.createApp({ 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 + })) } }, @@ -227,9 +297,14 @@ window.app = Vue.createApp({ 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: this.grantForm.account_id, + account_id: accountId, permission_type: this.grantForm.permission_type } @@ -332,9 +407,14 @@ window.app = Vue.createApp({ 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: this.bulkGrantForm.account_id, + account_id: accountId, permission_type: this.bulkGrantForm.permission_type } @@ -563,6 +643,460 @@ window.app = Vue.createApp({ 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: '' + } } }, @@ -576,7 +1110,9 @@ window.app = Vue.createApp({ await Promise.all([ this.loadPermissions(), this.loadUsers(), - this.loadEquityEligibleUsers() + this.loadEquityEligibleUsers(), + this.loadRoles(), + this.loadUserRoles() ]) } } diff --git a/templates/castle/permissions.html b/templates/castle/permissions.html index f38017d..0997d45 100644 --- a/templates/castle/permissions.html +++ b/templates/castle/permissions.html @@ -65,6 +65,7 @@ + @@ -77,20 +78,69 @@ -
+
-

No permissions granted yet

+

No permissions or roles assigned yet

- + -
- - User: {% raw %}{{ userId }}{% endraw %} +
+
+
+ + User: {% raw %}{{ userId }}{% endraw %} +
+
+
+ + Assign role to user + +
- - + + +
+
+ + Assigned Roles +
+
+ + {% raw %}{{ getRoleName(userRole.role_id) }}{% endraw %} + Click to view role details | Click X to revoke + +
+
+ + +
+ + Direct Permissions +
+ + @@ -102,6 +152,9 @@ {% raw %}{{ getPermissionLabel(perm.permission_type) }}{% endraw %} + + Direct + @@ -127,6 +180,9 @@ +
+ No direct permissions (permissions inherited from roles) +
@@ -193,6 +249,95 @@
+ + +
+ +
+ +
+
+ + Admin access required + +
+
+ +
+ +

No roles configured yet

+
+ +
+ + +
+
+
+ + {% raw %}{{ role.name }}{% endraw %} + + DEFAULT + +
+
+ {% raw %}{{ role.description }}{% endraw %} +
+
+ + {% raw %}{{ role.user_count }}{% endraw %} user(s) + + + {% raw %}{{ role.permission_count }}{% endraw %} permission(s) + +
+
+
+ + + View Details + + + Edit Role + + + Delete Role + + +
+
+
+
+
+
+
@@ -763,4 +908,503 @@ + + + + + +
{% raw %}{{ editingRole ? 'Edit Role' : 'Create Role' }}{% endraw %}
+
+ Define a role with a name and description. Permissions will be added separately. +
+
+ + + + + + + + + + + + + + + Only one role can be the default. Setting this will remove default status from other roles. + + + +
+ +
+ + Role Permissions + + Add permission to role + +
+ +
+ No permissions assigned to this role yet +
+ + + + + + + + + + {% raw %}{{ getAccountName(perm.account_id) }}{% endraw %} + + + {% raw %}{{ getPermissionLabel(perm.permission_type) }}{% endraw %} + + + + + + Remove permission + + + + +
+
+ + + + + +
+
+ + + + + +
Add Permission to Role
+
+ Grant this role access to an account +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + {% raw %}{{ selectedRole.name }}{% endraw %} + + DEFAULT + +
+
+ {% raw %}{{ selectedRole.description }}{% endraw %} +
+
+ + + + +
+ + Role Permissions ({% raw %}{{ rolePermissionsForView.length }}{% endraw %}) +
+ +
+ No permissions assigned to this role yet +
+ + + + + + + + + + {% raw %}{{ getAccountName(perm.account_id) }}{% endraw %} + + + {% raw %}{{ getPermissionLabel(perm.permission_type) }}{% endraw %} + + + + + + + + +
+ + Users with this role ({% raw %}{{ roleUsersForView.length }}{% endraw %}) +
+ +
+ No users assigned to this role yet +
+ + + + + {% raw %}{{ user.user_id }}{% endraw %} + + Granted: {% raw %}{{ formatDate(user.granted_at) }}{% endraw %} + | Expires: {% raw %}{{ formatDate(user.expires_at) }}{% endraw %} + + + + +
+ + + + +
+
+ + + + + +
Delete Role?
+
+ + +

Are you sure you want to delete this role?

+

+ + Warning: This will remove all permissions associated with this role + and revoke role assignments from all users. This action cannot be undone. +

+ + + + Role + {% raw %}{{ roleToDelete.name }}{% endraw %} + + + + + Affected Users + {% raw %}{{ roleToDelete.user_count }}{% endraw %} + + + + + Permissions + {% raw %}{{ roleToDelete.permission_count }}{% endraw %} + + + +
+ + + + + +
+
+ + + + + +
Revoke Role from User?
+
+ + +

Are you sure you want to revoke this role from the user? They will immediately lose all permissions associated with this role.

+ + + + User + {% raw %}{{ userRoleToRevoke.user_id }}{% endraw %} + + + + + Role + {% raw %}{{ getRoleName(userRoleToRevoke.role_id) }}{% endraw %} + + + + + Notes + {% raw %}{{ userRoleToRevoke.notes }}{% endraw %} + + + +
+ + + + + +
+
+ + + + + +
Assign User to Role
+
+ Assign a user to a role to grant them all permissions associated with that role. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
{% endblock %}