Enables equity eligibility for users

Allows superusers to grant and revoke equity eligibility for users.
Adds UI components for managing equity eligibility.
Equity-eligible users can then contribute expenses as equity.
This commit is contained in:
padreug 2025-11-08 10:14:24 +01:00
parent 33c294de7f
commit eefabc3441
4 changed files with 338 additions and 1 deletions

View file

@ -15,6 +15,7 @@ window.app = Vue.createApp({
users: [], users: [],
settings: null, settings: null,
userWalletSettings: null, userWalletSettings: null,
userInfo: null, // User information including equity eligibility
isAdmin: false, isAdmin: false,
isSuperUser: false, isSuperUser: false,
castleWalletConfigured: false, castleWalletConfigured: false,
@ -353,6 +354,19 @@ window.app = Vue.createApp({
console.error('Error loading users:', error) console.error('Error loading users:', error)
} }
}, },
async loadUserInfo() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/user/info',
this.g.user.wallets[0].inkey
)
this.userInfo = response.data
} catch (error) {
console.error('Error loading user info:', error)
this.userInfo = { is_equity_eligible: false }
}
},
async loadSettings() { async loadSettings() {
try { try {
// Try with admin key first to check settings // Try with admin key first to check settings
@ -1457,6 +1471,7 @@ window.app = Vue.createApp({
// Load settings first to determine if user is super user // Load settings first to determine if user is super user
await this.loadSettings() await this.loadSettings()
await this.loadUserWallet() await this.loadUserWallet()
await this.loadUserInfo()
await this.loadExchangeRate() await this.loadExchangeRate()
await this.loadBalance() await this.loadBalance()
await this.loadTransactions() await this.loadTransactions()

View file

@ -7,13 +7,19 @@ window.app = Vue.createApp({
accounts: [], accounts: [],
users: [], users: [],
filteredUsers: [], filteredUsers: [],
equityEligibleUsers: [],
loading: false, loading: false,
granting: false, granting: false,
revoking: false, revoking: false,
grantingEquity: false,
revokingEquity: false,
activeTab: 'by-user', activeTab: 'by-user',
showGrantDialog: false, showGrantDialog: false,
showRevokeDialog: false, showRevokeDialog: false,
showGrantEquityDialog: false,
showRevokeEquityDialog: false,
permissionToRevoke: null, permissionToRevoke: null,
equityToRevoke: null,
isSuperUser: false, isSuperUser: false,
grantForm: { grantForm: {
user_id: '', user_id: '',
@ -22,6 +28,10 @@ window.app = Vue.createApp({
notes: '', notes: '',
expires_at: '' expires_at: ''
}, },
grantEquityForm: {
user_id: '',
notes: ''
},
permissionTypeOptions: [ permissionTypeOptions: [
{ {
value: 'read', value: 'read',
@ -326,6 +336,124 @@ window.app = Vue.createApp({
if (!dateString) return '-' if (!dateString) return '-'
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleString() 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: ''
}
} }
}, },
@ -338,7 +466,8 @@ window.app = Vue.createApp({
if (this.isSuperUser) { if (this.isSuperUser) {
await Promise.all([ await Promise.all([
this.loadPermissions(), this.loadPermissions(),
this.loadUsers() this.loadUsers(),
this.loadEquityEligibleUsers()
]) ])
} }
} }

View file

@ -723,6 +723,7 @@
></q-select> ></q-select>
<q-select <q-select
v-if="userInfo && userInfo.is_equity_eligible"
filled filled
dense dense
v-model="expenseDialog.isEquity" v-model="expenseDialog.isEquity"
@ -735,8 +736,25 @@
emit-value emit-value
map-options map-options
label="Type *" label="Type *"
hint="Choose whether this is a liability (Castle owes you) or an equity contribution"
></q-select> ></q-select>
<!-- If user is not equity eligible, force liability -->
<div v-else>
<q-input
filled
dense
readonly
:model-value="'Liability (Castle owes me)'"
label="Type"
hint="This expense will be recorded as a liability (Castle owes you)"
>
<template v-slot:prepend>
<q-icon name="info" color="blue-grey-7"></q-icon>
</template>
</q-input>
</div>
<q-input <q-input
filled filled
dense dense

View file

@ -44,6 +44,7 @@
<q-tabs v-model="activeTab" class="text-primary" dense> <q-tabs v-model="activeTab" class="text-primary" dense>
<q-tab name="by-user" icon="people" label="By User"></q-tab> <q-tab name="by-user" icon="people" label="By User"></q-tab>
<q-tab name="by-account" icon="account_balance" label="By Account"></q-tab> <q-tab name="by-account" icon="account_balance" label="By Account"></q-tab>
<q-tab name="equity" icon="account_balance_wallet" label="Equity Eligibility"></q-tab>
</q-tabs> </q-tabs>
<q-separator></q-separator> <q-separator></q-separator>
@ -170,6 +171,68 @@
</q-card> </q-card>
</div> </div>
</q-tab-panel> </q-tab-panel>
<!-- Equity Eligibility View -->
<q-tab-panel name="equity">
<div v-if="loading" class="row justify-center q-pa-md">
<q-spinner color="primary" size="3em"></q-spinner>
</div>
<div class="row q-mb-md">
<div class="col">
<q-btn
color="primary"
icon="add"
label="Grant Equity Eligibility"
@click="showGrantEquityDialog = true"
:disable="!isSuperUser"
>
<q-tooltip v-if="!isSuperUser">Admin access required</q-tooltip>
</q-btn>
</div>
</div>
<div v-if="equityEligibleUsers.length === 0" class="text-center q-pa-md">
<q-icon name="info" size="3em" color="grey-5"></q-icon>
<p class="text-grey-6">No equity-eligible users yet</p>
</div>
<div v-else class="q-gutter-md">
<q-card v-for="equity in equityEligibleUsers" :key="equity.user_id" flat bordered>
<q-card-section>
<div class="row items-center">
<div class="col">
<div class="text-h6">
<q-icon name="person" class="q-mr-sm"></q-icon>
{% raw %}{{ equity.user_id }}{% endraw %}
</div>
<div class="text-caption text-grey-7 q-mt-sm">
<strong>Equity Account:</strong> {% raw %}{{ equity.equity_account_name }}{% endraw %}
</div>
<div v-if="equity.notes" class="text-caption text-grey-7 q-mt-xs">
<strong>Notes:</strong> {% raw %}{{ equity.notes }}{% endraw %}
</div>
<div class="text-caption text-grey-6 q-mt-xs">
Granted: {% raw %}{{ formatDate(equity.granted_at) }}{% endraw %}
</div>
</div>
<div class="col-auto">
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="confirmRevokeEquity(equity)"
>
<q-tooltip>Revoke Equity Eligibility</q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
</q-card>
</div>
</q-tab-panel>
</q-tab-panels> </q-tab-panels>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -348,4 +411,116 @@
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Grant Equity Eligibility Dialog -->
<q-dialog v-model="showGrantEquityDialog" position="top">
<q-card class="q-pa-lg" style="min-width: 500px">
<q-card-section>
<div class="text-h6">Grant Equity Eligibility</div>
<div class="text-caption text-grey-7">
Grant a user the ability to contribute expenses as equity instead of liability.
An equity account will be automatically created for this user.
</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<!-- User -->
<q-select
v-model="grantEquityForm.user_id"
label="User *"
hint="Search and select a user"
:options="userOptions"
option-value="id"
option-label="label"
emit-value
map-options
use-input
@filter="filterUsers"
outlined
dense
:rules="[val => !!val || 'User is required']"
>
<template v-slot:prepend>
<q-icon name="person"></q-icon>
</template>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
No users found
</q-item-section>
</q-item>
</template>
</q-select>
<!-- Notes (Optional) -->
<q-input
v-model="grantEquityForm.notes"
label="Notes (Optional)"
hint="Optional notes for admin reference"
type="textarea"
outlined
dense
rows="3"
>
<template v-slot:prepend>
<q-icon name="note"></q-icon>
</template>
</q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" color="grey" v-close-popup></q-btn>
<q-btn
unelevated
label="Grant Equity Eligibility"
color="primary"
@click="grantEquityEligibility"
:loading="grantingEquity"
:disable="!grantEquityForm.user_id"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- Revoke Equity Confirmation Dialog -->
<q-dialog v-model="showRevokeEquityDialog" persistent>
<q-card>
<q-card-section>
<div class="text-h6">Revoke Equity Eligibility?</div>
</q-card-section>
<q-card-section v-if="equityToRevoke">
<p>Are you sure you want to revoke equity eligibility for this user?</p>
<p class="text-caption text-grey-7">
<strong>Note:</strong> This will prevent them from adding new expenses as equity contributions.
Their existing equity account and contributions will remain unchanged.
</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 %}{{ equityToRevoke.user_id }}{% endraw %}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>Equity Account</q-item-label>
<q-item-label>{% raw %}{{ equityToRevoke.equity_account_name }}{% 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>
<q-btn
unelevated
label="Revoke"
color="negative"
@click="revokeEquityEligibility"
:loading="revokingEquity"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %} {% endblock %}