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 @@
No permissions granted yet
+No permissions or roles assigned yet
No roles configured yet
+Are you sure you want to delete this role?
+
+
Are you sure you want to revoke this role from the user? They will immediately lose all permissions associated with this role.
+