Adds permission management UI and logic
Implements a Vue-based UI for managing user permissions, allowing administrators to grant and revoke access to expense accounts. Provides views for managing permissions by user and by account, along with dialogs for granting and revoking permissions. Integrates with the LNbits API to load accounts and permissions and to persist changes.
This commit is contained in:
parent
92c1649f3b
commit
9c63511371
2 changed files with 628 additions and 0 deletions
292
static/js/permissions.js
Normal file
292
static/js/permissions.js
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
permissions: [],
|
||||
accounts: [],
|
||||
loading: false,
|
||||
granting: false,
|
||||
revoking: false,
|
||||
activeTab: 'by-user',
|
||||
showGrantDialog: false,
|
||||
showRevokeDialog: false,
|
||||
permissionToRevoke: null,
|
||||
isSuperUser: false,
|
||||
grantForm: {
|
||||
user_id: '',
|
||||
account_id: '',
|
||||
permission_type: 'read',
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
accountOptions() {
|
||||
return this.accounts.map(acc => ({
|
||||
id: acc.id,
|
||||
name: acc.name
|
||||
}))
|
||||
},
|
||||
|
||||
isGrantFormValid() {
|
||||
return !!(
|
||||
this.grantForm.user_id &&
|
||||
this.grantForm.account_id &&
|
||||
this.grantForm.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
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
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 {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/accounts',
|
||||
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 grantPermission() {
|
||||
if (!this.isGrantFormValid) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Please fill in all required fields',
|
||||
timeout: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.granting = true
|
||||
try {
|
||||
const payload = {
|
||||
user_id: this.grantForm.user_id,
|
||||
account_id: this.grantForm.account_id,
|
||||
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: 'read',
|
||||
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 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 this.loadPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
window.app.mount('#vue')
|
||||
336
templates/castle/permissions.html
Normal file
336
templates/castle/permissions.html
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('castle/static', path='js/permissions.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="q-my-none">🔐 Permission Management</h5>
|
||||
<p class="q-mb-none">Manage user access to expense accounts</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="Grant Permission"
|
||||
@click="showGrantDialog = true"
|
||||
:disable="!isSuperUser"
|
||||
>
|
||||
<q-tooltip v-if="!isSuperUser">Admin access required</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Check Banner -->
|
||||
<q-banner v-if="!isSuperUser" class="bg-warning text-white q-mb-md" rounded>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="lock" color="white"></q-icon>
|
||||
</template>
|
||||
<div>
|
||||
<strong>Admin Access Required:</strong> You must be a super user to manage permissions.
|
||||
</div>
|
||||
</q-banner>
|
||||
|
||||
<!-- Tabs for different views -->
|
||||
<q-tabs v-model="activeTab" class="text-primary" dense>
|
||||
<q-tab name="by-user" icon="people" label="By User" />
|
||||
<q-tab name="by-account" icon="account_balance" label="By Account" />
|
||||
</q-tabs>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-tab-panels v-model="activeTab" animated>
|
||||
<!-- By User View -->
|
||||
<q-tab-panel name="by-user">
|
||||
<div v-if="loading" class="row justify-center q-pa-md">
|
||||
<q-spinner color="primary" size="3em" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="permissionsByUser.size === 0" class="text-center q-pa-md">
|
||||
<q-icon name="info" size="3em" color="grey-5" />
|
||||
<p class="text-grey-6">No permissions granted yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="q-gutter-md">
|
||||
<q-card v-for="[userId, userPerms] in permissionsByUser" :key="userId" flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-h6 q-mb-sm">
|
||||
<q-icon name="person" class="q-mr-sm" />
|
||||
User: {% raw %}{{ userId }}{% endraw %}
|
||||
</div>
|
||||
<q-list separator>
|
||||
<q-item v-for="perm in userPerms" :key="perm.id">
|
||||
<q-item-section avatar>
|
||||
<q-avatar :color="getPermissionColor(perm.permission_type)" text-color="white" size="sm">
|
||||
<q-icon :name="getPermissionIcon(perm.permission_type)" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{% raw %}{{ getAccountName(perm.account_id) }}{% endraw %}</q-item-label>
|
||||
<q-item-label caption>
|
||||
<q-chip size="sm" :color="getPermissionColor(perm.permission_type)" text-color="white">
|
||||
{% raw %}{{ getPermissionLabel(perm.permission_type) }}{% endraw %}
|
||||
</q-chip>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label caption>Granted: {% raw %}{{ formatDate(perm.granted_at) }}{% endraw %}</q-item-label>
|
||||
<q-item-label caption v-if="perm.expires_at">
|
||||
Expires: {% raw %}{{ formatDate(perm.expires_at) }}{% endraw %}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="perm.notes" class="text-grey-7">
|
||||
{% raw %}{{ perm.notes }}{% endraw %}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="confirmRevokePermission(perm)"
|
||||
>
|
||||
<q-tooltip>Revoke Permission</q-tooltip>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- By Account View -->
|
||||
<q-tab-panel name="by-account">
|
||||
<div v-if="loading" class="row justify-center q-pa-md">
|
||||
<q-spinner color="primary" size="3em" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="permissionsByAccount.size === 0" class="text-center q-pa-md">
|
||||
<q-icon name="info" size="3em" color="grey-5" />
|
||||
<p class="text-grey-6">No permissions granted yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="q-gutter-md">
|
||||
<q-card v-for="[accountId, accountPerms] in permissionsByAccount" :key="accountId" flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-h6 q-mb-sm">
|
||||
<q-icon name="account_balance" class="q-mr-sm" />
|
||||
{% raw %}{{ getAccountName(accountId) }}{% endraw %}
|
||||
</div>
|
||||
<q-list separator>
|
||||
<q-item v-for="perm in accountPerms" :key="perm.id">
|
||||
<q-item-section avatar>
|
||||
<q-avatar :color="getPermissionColor(perm.permission_type)" text-color="white" size="sm">
|
||||
<q-icon :name="getPermissionIcon(perm.permission_type)" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{% raw %}{{ perm.user_id }}{% endraw %}</q-item-label>
|
||||
<q-item-label caption>
|
||||
<q-chip size="sm" :color="getPermissionColor(perm.permission_type)" text-color="white">
|
||||
{% raw %}{{ getPermissionLabel(perm.permission_type) }}{% endraw %}
|
||||
</q-chip>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label caption>Granted: {% raw %}{{ formatDate(perm.granted_at) }}{% endraw %}</q-item-label>
|
||||
<q-item-label caption v-if="perm.expires_at">
|
||||
Expires: {% raw %}{{ formatDate(perm.expires_at) }}{% endraw %}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="perm.notes" class="text-grey-7">
|
||||
{% raw %}{{ perm.notes }}{% endraw %}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="confirmRevokePermission(perm)"
|
||||
>
|
||||
<q-tooltip>Revoke Permission</q-tooltip>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grant Permission Dialog -->
|
||||
<q-dialog v-model="showGrantDialog" position="top">
|
||||
<q-card class="q-pa-lg" style="min-width: 500px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Grant Account Permission</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
Grant a user permission to access an expense account. Permissions on parent accounts cascade to children.
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-gutter-md">
|
||||
<!-- User ID -->
|
||||
<q-input
|
||||
v-model="grantForm.user_id"
|
||||
label="User ID *"
|
||||
hint="Wallet ID of the user"
|
||||
outlined
|
||||
dense
|
||||
:rules="[val => !!val || 'User ID is required']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<!-- Account -->
|
||||
<q-select
|
||||
v-model="grantForm.account_id"
|
||||
label="Account *"
|
||||
hint="Account to grant access to"
|
||||
:options="accountOptions"
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
emit-value
|
||||
map-options
|
||||
outlined
|
||||
dense
|
||||
:rules="[val => !!val || 'Account is required']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="account_balance" />
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<!-- Permission Type -->
|
||||
<q-select
|
||||
v-model="grantForm.permission_type"
|
||||
label="Permission Type *"
|
||||
hint="Type of permission to grant"
|
||||
:options="permissionTypeOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
outlined
|
||||
dense
|
||||
:rules="[val => !!val || 'Permission type is required']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="security" />
|
||||
</template>
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section>
|
||||
<q-item-label>{% raw %}{{ scope.opt.label }}{% endraw %}</q-item-label>
|
||||
<q-item-label caption>{% raw %}{{ scope.opt.description }}{% endraw %}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<!-- Expiration Date (Optional) -->
|
||||
<q-input
|
||||
v-model="grantForm.expires_at"
|
||||
label="Expiration Date (Optional)"
|
||||
hint="Leave empty for permanent access"
|
||||
type="datetime-local"
|
||||
outlined
|
||||
dense
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="event" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<!-- Notes (Optional) -->
|
||||
<q-input
|
||||
v-model="grantForm.notes"
|
||||
label="Notes (Optional)"
|
||||
hint="Optional notes for admin reference"
|
||||
type="textarea"
|
||||
outlined
|
||||
dense
|
||||
rows="3"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="note" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" color="grey" v-close-popup />
|
||||
<q-btn
|
||||
unelevated
|
||||
label="Grant Permission"
|
||||
color="primary"
|
||||
@click="grantPermission"
|
||||
:loading="granting"
|
||||
:disable="!isGrantFormValid"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Revoke Confirmation Dialog -->
|
||||
<q-dialog v-model="showRevokeDialog" persistent>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Revoke Permission?</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="permissionToRevoke">
|
||||
<p>Are you sure you want to revoke this permission? The user will immediately lose access.</p>
|
||||
<q-list dense bordered class="rounded-borders q-mt-md">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>User</q-item-label>
|
||||
<q-item-label>{% raw %}{{ permissionToRevoke.user_id }}{% endraw %}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>Account</q-item-label>
|
||||
<q-item-label>{% raw %}{{ getAccountName(permissionToRevoke.account_id) }}{% endraw %}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>Permission Type</q-item-label>
|
||||
<q-item-label>{% raw %}{{ getPermissionLabel(permissionToRevoke.permission_type) }}{% endraw %}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" color="grey" v-close-popup />
|
||||
<q-btn
|
||||
unelevated
|
||||
label="Revoke"
|
||||
color="negative"
|
||||
@click="revokePermission"
|
||||
:loading="revoking"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue