[feat] Payment labels (#3537)

This commit is contained in:
Vlad Stan 2025-11-21 10:33:53 +02:00 committed by GitHub
parent 3ccefb70fa
commit 7c7a04da9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 930 additions and 39 deletions

View file

@ -292,6 +292,7 @@ async def create_payment(
fee=-abs(data.fee), fee=-abs(data.fee),
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [],
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)

View file

@ -778,3 +778,11 @@ async def m037_create_assets_table(db: Connection):
); );
""" """
) )
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)

View file

@ -59,6 +59,7 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel): class Payment(BaseModel):
@ -81,6 +82,7 @@ class Payment(BaseModel):
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {} extra: dict = {}
def __init__(self, **data): def __init__(self, **data):
@ -184,7 +186,15 @@ class Payment(BaseModel):
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"] __search_fields__ = [
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"] __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
@ -198,6 +208,7 @@ class PaymentFilters(FilterModel):
preimage: str | None preimage: str | None
payment_hash: str | None payment_hash: str | None
wallet_id: str | None wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel): class PaymentDataPoint(BaseModel):
@ -272,6 +283,7 @@ class CreateInvoice(BaseModel):
bolt11: str | None = None bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@ -320,3 +332,7 @@ class CancelInvoice(BaseModel):
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []

View file

@ -12,6 +12,7 @@ from lnbits.db import FilterModel
from lnbits.helpers import ( from lnbits.helpers import (
is_valid_email_address, is_valid_email_address,
is_valid_external_id, is_valid_external_id,
is_valid_label,
is_valid_pubkey, is_valid_pubkey,
is_valid_username, is_valid_username,
) )
@ -36,6 +37,14 @@ class WalletInviteRequest(BaseModel):
to_wallet_name: str to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel): class UserExtra(BaseModel):
email_verified: bool | None = False email_verified: bool | None = False
first_name: str | None = None first_name: str | None = None
@ -55,6 +64,8 @@ class UserExtra(BaseModel):
wallet_invite_requests: list[WalletInviteRequest] = [] wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request( def add_wallet_invite_request(
self, self,
request_id: str, request_id: str,
@ -78,6 +89,18 @@ class UserExtra(BaseModel):
return invite return invite
return None return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request( def remove_wallet_invite_request(
self, self,
request_id: str, request_id: str,
@ -202,6 +225,8 @@ class Account(BaseModel):
if user_uuid4.hex != self.id: if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.") raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account): class AccountOverview(Account):
transaction_count: int | None = 0 transaction_count: int | None = 0

View file

@ -61,6 +61,7 @@ async def pay_invoice(
extra: dict | None = None, extra: dict | None = None,
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
@ -91,6 +92,7 @@ async def pay_invoice(
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels,
) )
payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn) payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
@ -207,6 +209,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
webhook=data.webhook, webhook=data.webhook,
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
labels=data.labels,
) )
if data.lnurl_withdraw: if data.lnurl_withdraw:
@ -246,6 +249,7 @@ async def create_invoice(
webhook: str | None = None, webhook: str | None = None,
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@ -329,6 +333,7 @@ async def create_invoice(
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels,
) )
payment = await create_payment( payment = await create_payment(

View file

@ -25,6 +25,7 @@ from lnbits.core.models.users import (
UpdateAccessControlList, UpdateAccessControlList,
) )
from lnbits.core.services import create_user_account from lnbits.core.services import create_user_account
from lnbits.core.services.users import update_user_account
from lnbits.decorators import access_token_payload, check_user_exists from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
@ -422,15 +423,6 @@ async def update(
) -> User | None: ) -> User | None:
if data.user_id != user.id: if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id) account = await get_account(user.id)
if not account: if not account:
@ -441,7 +433,7 @@ async def update(
if data.extra: if data.extra:
account.extra = data.extra account.extra = data.extra
await update_account(account) await update_user_account(account)
return await get_user_from_account(account) return await get_user_from_account(account)

View file

@ -183,7 +183,7 @@ async def manifest(request: Request, usr: str):
"src": ( "src": (
settings.lnbits_custom_logo settings.lnbits_custom_logo
if settings.lnbits_custom_logo if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png" else "images/logos/lnbits.png"
), ),
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",

View file

@ -15,7 +15,9 @@ from lnbits import bolt11
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
get_payment_count_stats, get_payment_count_stats,
get_wallets_stats, get_wallets_stats,
update_payment,
) )
from lnbits.core.crud.users import get_account
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice, CancelInvoice,
CreateInvoice, CreateInvoice,
@ -32,6 +34,7 @@ from lnbits.core.models import (
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
) )
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import User from lnbits.core.models.users import User
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
@ -247,6 +250,7 @@ async def api_payments_create(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels,
) )
return payment return payment
@ -260,6 +264,27 @@ async def api_payments_create(
return await create_payment_request(wallet_id, invoice_data) return await create_payment_request(wallet_id, invoice_data)
@payment_router.put("/{payment_hash}/labels")
async def api_update_payment_labels(
payment_hash: str,
data: UpdatePaymentLabels,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> SimpleStatus:
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
account = await get_account(key_type.wallet.user)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account does not exist.")
# only keep labels that belong to the user
user_label_names = [label.name for label in account.extra.labels]
payment.labels = [label for label in data.labels if label in user_label_names]
await update_payment(payment)
return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice) invoice_obj = bolt11.decode(invoice)

View file

@ -431,6 +431,8 @@ class Operator(Enum):
INCLUDE = "in" INCLUDE = "in"
EXCLUDE = "ex" EXCLUDE = "ex"
LIKE = "like" LIKE = "like"
EVERY = "every"
ANY = "any"
@property @property
def as_sql(self): def as_sql(self):
@ -450,7 +452,7 @@ class Operator(Enum):
return ">=" return ">="
elif self == Operator.LE: elif self == Operator.LE:
return "<=" return "<="
elif self == Operator.LIKE: elif self in {Operator.LIKE, Operator.EVERY, Operator.ANY}:
return "LIKE" return "LIKE"
else: else:
raise ValueError("Unknown SQL Operator") raise ValueError("Unknown SQL Operator")
@ -481,6 +483,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
def parse_query( def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0 cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
): ):
if i > 1000 or len(raw_values) > 1000:
raise ValueError("Too many filter values")
# Key format: # Key format:
# key[operator] # key[operator]
# e.g. name[eq] # e.g. name[eq]
@ -497,11 +501,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__: if field in model.__fields__:
compare_field = model.__fields__[field] compare_field = model.__fields__[field]
values: dict = {} values: dict = {}
for raw_value in raw_values: if op in {Operator.EVERY, Operator.ANY}:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none") validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors: if errors:
raise ValidationError(errors=[errors], model=model) raise ValidationError(errors=[errors], model=model)
values[f"{field}__{i}"] = validated values[f"{field}__{index}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
@ -514,10 +521,16 @@ class Filter(BaseModel, Generic[TFilterModel]):
clean_key = key.split("__")[0] clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime: if self.model and self.model.__fields__[clean_key].type_ == datetime:
placeholder = compat_timestamp_placeholder(key) placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
else: else:
placeholder = f":{key}" stmt.append(f"{clean_key} {self.op.as_sql} :{key}")
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt) if self.op == Operator.EVERY:
statement = " AND ".join(stmt)
else:
statement = " OR ".join(stmt)
return f"({statement})"
class Filters(BaseModel, Generic[TFilterModel]): class Filters(BaseModel, Generic[TFilterModel]):
@ -593,6 +606,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
for key, value in page_filter.values.items(): for key, value in page_filter.values.items():
if page_filter.op == Operator.LIKE: if page_filter.op == Operator.LIKE:
values[key] = f"%{value}%" values[key] = f"%{value}%"
elif page_filter.op in {Operator.EVERY, Operator.ANY}:
values[key] = f"""%"{value}"%"""
else: else:
values[key] = value values[key] = value
if self.search and self.model: if self.search and self.model:

View file

@ -201,6 +201,11 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None return re.fullmatch(username_regex, username) is not None
def is_valid_label(label: str) -> bool:
label_regex = r"([A-Za-z0-9 ._-]{1,100}$)"
return re.fullmatch(label_regex, label) is not None
def is_valid_external_id(external_id: str) -> bool: def is_valid_external_id(external_id: str) -> bool:
if len(external_id) > 256: if len(external_id) > 256:
return False return False

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -25,12 +25,14 @@ window.localisation.en = {
reconnect: 'Reconnect', reconnect: 'Reconnect',
open_channel: 'Open Channel', open_channel: 'Open Channel',
open: 'Open', open: 'Open',
clear: 'Clear',
close_channel: 'Close Channel', close_channel: 'Close Channel',
close: 'Close', close: 'Close',
restart: 'Restart server', restart: 'Restart server',
image_library: 'Image Library', image_library: 'Image Library',
save: 'Save', save: 'Save',
save_tooltip: 'Save your changes', save_tooltip: 'Save your changes',
must_save: 'You have unsaved changes',
credit_debit: 'Credit / Debit', credit_debit: 'Credit / Debit',
credit_hint: 'Press Enter to credit/debit wallet (negative values allowed)', credit_hint: 'Press Enter to credit/debit wallet (negative values allowed)',
credit_label: '{denomination} to credit/debit', credit_label: '{denomination} to credit/debit',
@ -694,6 +696,7 @@ window.localisation.en = {
view_list: 'View wallets as list', view_list: 'View wallets as list',
view_column: 'View wallets as rows', view_column: 'View wallets as rows',
filter_payments: 'Filter payments', filter_payments: 'Filter payments',
filter_labels: 'Filter labels',
filter_date: 'Filter by date', filter_date: 'Filter by date',
websocket_example: 'Websocket example', websocket_example: 'Websocket example',
secret_key: 'Secret Key', secret_key: 'Secret Key',
@ -709,5 +712,15 @@ window.localisation.en = {
paid: 'Paid', paid: 'Paid',
funding_source_retries: 'Max Retries', funding_source_retries: 'Max Retries',
funding_source_retries_desc: funding_source_retries_desc:
'Maximum number of retries for funding sources, before it falls back to VoidWallet.' 'Maximum number of retries for funding sources, before it falls back to VoidWallet.',
add_label: 'Add Label',
label: 'Label',
labels: 'Labels',
no_labels_defined: 'No labels defined yet',
manage_labels: 'Manage Labels',
update_label: 'Update Label',
delete_label: 'Delete Label',
add_remove_labels: 'Add or Remove Labels',
payment_labels_updated: 'Payment labels updated',
color: 'Color'
} }

View file

@ -94,7 +94,8 @@ window.LNbits = {
webhook: data.webhook, webhook: data.webhook,
webhook_status: data.webhook_status, webhook_status: data.webhook_status,
fiat_amount: data.fiat_amount, fiat_amount: data.fiat_amount,
fiat_currency: data.fiat_currency fiat_currency: data.fiat_currency,
labels: data.labels
} }
obj.date = moment.utc(data.created_at).local().format(window.dateFormat) obj.date = moment.utc(data.created_at).local().format(window.dateFormat)

View file

@ -0,0 +1,36 @@
window.app.component('lnbits-label-selector', {
template: '#lnbits-label-selector',
props: ['labels'],
mixins: [window.windowMixin],
data() {
return {
labelFilter: '',
localLabels: []
}
},
methods: {
toggleLabel(label) {
const hasLabel = this.localLabels.includes(label.name)
if (hasLabel) {
const index = this.localLabels.indexOf(label.name)
if (index !== -1) {
this.localLabels.splice(index, 1)
}
} else {
this.localLabels.push(label.name)
}
},
saveLabels() {
this.$emit('update:labels', this.localLabels)
},
clearLabels() {
this.localLabels = []
this.saveLabels()
}
},
created() {
this.localLabels = [...this.labels]
}
})

View file

@ -121,7 +121,9 @@ window.app.component('lnbits-payment-list', {
show: false, show: false,
payment: null, payment: null,
preimage: null preimage: null
} },
selectedPayment: null,
filterLabels: []
} }
}, },
computed: { computed: {
@ -161,12 +163,26 @@ window.app.component('lnbits-payment-list', {
this.fetchPayments() this.fetchPayments()
}, },
searchByLabels(labels) {
if (!labels || labels.length === 0) {
this.clearLabelSeach()
return
}
this.filterLabels = labels
this.paymentsTable.filter['labels[every]'] = labels
this.fetchPayments()
},
clearDateSeach() { clearDateSeach() {
this.searchDate = {from: null, to: null} this.searchDate = {from: null, to: null}
delete this.paymentFilter['time[ge]'] delete this.paymentFilter['time[ge]']
delete this.paymentFilter['time[le]'] delete this.paymentFilter['time[le]']
this.fetchPayments() this.fetchPayments()
}, },
clearLabelSeach() {
this.filterLabels = []
delete this.paymentsTable.filter['labels[every]']
this.fetchPayments()
},
fetchPayments(props) { fetchPayments(props) {
const params = LNbits.utils.prepareFilterQuery( const params = LNbits.utils.prepareFilterQuery(
this.paymentsTable, this.paymentsTable,
@ -354,6 +370,47 @@ window.app.component('lnbits-payment-list', {
paymentFilter['amount[le]'] = 0 paymentFilter['amount[le]'] = 0
} }
this.paymentFilter = paymentFilter this.paymentFilter = paymentFilter
},
async savePaymentLabels(labels) {
if (!this.selectedPayment) {
Quasar.Notify.create({
type: 'warning',
message: 'No payment selected'
})
return
}
try {
await LNbits.api.request(
'PUT',
`/api/v1/payments/${this.selectedPayment.payment_hash}/labels`,
this.g.wallet.adminkey,
{
labels: labels
}
)
const payment = this.payments.find(
p => p.checking_id === this.selectedPayment.checking_id
)
if (payment) {
payment.labels = [...labels]
}
Quasar.Notify.create({
type: 'positive',
message: this.$t('payment_labels_updated')
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
isLightColor(color) {
try {
return Quasar.colors.luminosity(color) > 0.5
} catch (e) {
console.warning(e)
return false
}
} }
}, },
watch: { watch: {

View file

@ -4,6 +4,7 @@ window.PageAccount = {
data() { data() {
return { return {
user: null, user: null,
untouchedUser: null,
hasUsername: false, hasUsername: false,
showUserId: false, showUserId: false,
themeOptions: [ themeOptions: [
@ -145,6 +146,47 @@ window.PageAccount = {
nostr: { nostr: {
identifier: '' identifier: ''
} }
},
labels: [],
labelsDialog: {
show: false,
data: {
name: '',
description: '',
color: '#000000'
}
},
labelsTable: {
loading: false,
columns: [
{
name: 'actions',
align: 'left'
},
{
name: 'name',
align: 'left',
label: this.$t('Name'),
field: 'name',
sortable: true
},
{
name: 'description',
align: 'left',
label: this.$t('description'),
field: 'description'
},
{
name: 'color',
align: 'left',
label: this.$t('color'),
field: 'color'
}
],
pagination: {
rowsPerPage: 6,
page: 1
}
} }
} }
}, },
@ -159,6 +201,11 @@ window.PageAccount = {
} }
} }
}, },
computed: {
isUserTouched() {
return JSON.stringify(this.user) !== JSON.stringify(this.untouchedUser)
}
},
methods: { methods: {
activeLanguage(lang) { activeLanguage(lang) {
return window.i18n.global.locale === lang return window.i18n.global.locale === lang
@ -181,6 +228,7 @@ window.PageAccount = {
} }
) )
this.user = data this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username this.hasUsername = !!data.username
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
@ -220,6 +268,7 @@ window.PageAccount = {
} }
) )
this.user = data this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username this.hasUsername = !!data.username
this.credentialsData.show = false this.credentialsData.show = false
Quasar.Notify.create({ Quasar.Notify.create({
@ -242,6 +291,7 @@ window.PageAccount = {
} }
) )
this.user = data this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username this.hasUsername = !!data.username
this.credentialsData.show = false this.credentialsData.show = false
this.$q.notify({ this.$q.notify({
@ -558,6 +608,73 @@ window.PageAccount = {
copyAssetLinkToClipboard(asset) { copyAssetLinkToClipboard(asset) {
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/binary` const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/binary`
this.copyText(assetUrl) this.copyText(assetUrl)
},
addUserLabel() {
if (!this.labelsDialog.data.name) {
this.$q.notify({
type: 'warning',
message: 'Name is required.'
})
return
}
if (!this.labelsDialog.data.color) {
this.$q.notify({
type: 'warning',
message: 'Color is required.'
})
return
}
this.user.extra.labels = this.user.extra.labels || []
const duplicate = this.user.extra.labels.find(
label => label.name === this.labelsDialog.data.name
)
if (duplicate) {
this.$q.notify({
type: 'warning',
message: 'A label with this name already exists.'
})
return
}
this.user.extra.labels.unshift({...this.labelsDialog.data})
this.labelsDialog.show = false
return true
},
openAddLabelDialog() {
this.labelsDialog.data = {
name: '',
description: '',
color: '#000000'
}
this.labelsDialog.show = true
},
openEditLabelDialog(label) {
this.labelsDialog.data = {
name: label.name,
description: label.description,
color: label.color
}
this.labelsDialog.show = true
},
updateUserLabel() {
const label = this.labelsDialog.data
const existingLabels = JSON.parse(JSON.stringify(this.user.extra.labels))
this.user.extra.labels = this.user.extra.labels.filter(
l => l.name !== label.name
)
const labelUpdated = this.addUserLabel()
if (!labelUpdated) {
this.user.extra.labels = existingLabels
}
this.labelsDialog.show = false
},
deleteUserLabel(label) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this label?')
.onOk(() => {
this.user.extra.labels = this.user.extra.labels.filter(
l => l.name !== label.name
)
})
} }
}, },
@ -565,6 +682,7 @@ window.PageAccount = {
try { try {
const {data} = await LNbits.api.getAuthenticatedUser() const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username this.hasUsername = !!data.username
if (!this.user.extra) this.user.extra = {} if (!this.user.extra) this.user.extra = {}
} catch (e) { } catch (e) {

View file

@ -84,6 +84,7 @@
"js/components/lnbits-manage-wallet-list.js", "js/components/lnbits-manage-wallet-list.js",
"js/components/lnbits-language-dropdown.js", "js/components/lnbits-language-dropdown.js",
"js/components/lnbits-payment-list.js", "js/components/lnbits-payment-list.js",
"js/components/lnbits-label-selector.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/data-fields.js", "js/components/data-fields.js",
"js/components.js", "js/components.js",

View file

@ -21,6 +21,7 @@ include('components/lnbits-manage-wallet-list.vue') %} {%
include('components/lnbits-language-dropdown.vue') %} {% include('components/lnbits-language-dropdown.vue') %} {%
include('components/lnbits-payment-list.vue') %} {% include('components/lnbits-payment-list.vue') %} {%
include('components/lnbits-wallet-new.vue') %} {% include('components/lnbits-wallet-new.vue') %} {%
include('components/lnbits-label-selector.vue') %} {%
include('components/lnbits-wallet-api-docs.vue') %} {% include('components/lnbits-wallet-api-docs.vue') %} {%
include('components/lnbits-wallet-share.vue') %} {% include('components/lnbits-wallet-share.vue') %} {%
include('components/lnbits-wallet-charts.vue') %} include('components/lnbits-wallet-charts.vue') %}

View file

@ -0,0 +1,107 @@
<template id="lnbits-label-selector">
<div v-if="g.user.extra.labels?.length">
<q-item header>
<q-input
v-model="labelFilter"
:label="$t('filter_labels')"
class="full-width"
filled
dense
autofocus
>
</q-input>
</q-item>
<q-separator></q-separator>
<q-scroll-area style="height: 230px; max-width: 300px">
<div v-for="label in g.user.extra.labels" :key="label.name">
<q-item
v-if="
!labelFilter ||
label.name.toLowerCase().includes(labelFilter.toLowerCase())
"
clickable
v-ripple
>
<q-item-section avatar top>
<q-checkbox
:model-value="localLabels.includes(label.name)"
@click="toggleLabel(label)"
dense
>
</q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label lines="1"
><span v-text="label.name"></span
></q-item-label>
<q-item-label caption
><span v-text="label.description"></span
></q-item-label>
</q-item-section>
<q-item-section side>
<q-badge
class="q-pa-sm"
size="xs"
rounded
:style="{
backgroundColor: label.color,
color: 'white'
}"
>
<span v-text="label.color"></span>
</q-badge>
</q-item-section>
</q-item>
</div>
</q-scroll-area>
<q-item footer>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-none"
icon="highlight_off"
@click="clearLabels"
>
<q-tooltip>
<span v-text="$t('clear')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-close-popup
flat
href="/account#labels"
color="grey"
icon="settings"
class="q-ml-auto float-right"
>
<q-tooltip>
<span v-text="$t('manage_labels')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-close-popup
flat
color="primary"
class="q-ml-none"
:label="$t('ok')"
@click="saveLabels"
>
<q-tooltip>
<span v-text="$t('manage_labels')"></span>
</q-tooltip>
</q-btn>
</q-item>
</div>
<div v-else class="q-pa-md">
<p v-text="$t('no_labels_defined')"></p>
<q-btn
flat
href="/account#labels"
color="primary"
:label="$t('manage_labels')"
></q-btn>
</div>
</template>

View file

@ -22,7 +22,12 @@
</q-input> </q-input>
</div> </div>
<div class="gt-sm col-auto"> <div class="gt-sm col-auto">
<q-btn icon="event" flat color="grey"> <q-btn icon="event" flat color="grey" class="q-pa-sm">
<q-badge
v-if="searchDate?.to || searchDate?.from"
color="primary"
floating
></q-badge>
<q-popup-proxy cover transition-show="scale" transition-hide="scale"> <q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="searchDate" mask="YYYY-MM-DD" range /> <q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row"> <div class="row">
@ -48,19 +53,30 @@
</div> </div>
</div> </div>
</q-popup-proxy> </q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
></q-badge>
<q-tooltip> <q-tooltip>
<span v-text="$t('filter_date')"></span> <span v-text="$t('filter_date')"></span>
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<q-btn color="grey" icon="filter_alt" flat> <q-btn icon="local_offer" class="q-pa-sm" flat color="grey">
<q-badge
v-if="filterLabels.length"
color="primary"
size="xs"
floating
v-text="filterLabels.length"
></q-badge>
<q-tooltip>
<span v-text="$t('label_filter')"></span>
</q-tooltip>
<q-popup-edit class="text-white q-pa-none">
<lnbits-label-selector
:labels="filterLabels"
@update:labels="searchByLabels"
></lnbits-label-selector>
</q-popup-edit>
</q-btn>
<q-btn color="grey" icon="filter_alt" class="q-pa-sm" flat>
<q-menu> <q-menu>
<q-item dense> <q-item dense>
<q-checkbox <q-checkbox
@ -109,7 +125,7 @@
persistent persistent
icon="archive" icon="archive"
split split
class="q-mr-sm" class="q-mr-sm q-pa-sm"
color="grey" color="grey"
@click="exportCSV(false)" @click="exportCSV(false)"
> >
@ -263,6 +279,42 @@
<span class="text-grey-5" v-text="props.row.dateFrom"></span> <span class="text-grey-5" v-text="props.row.dateFrom"></span>
<q-tooltip><span v-text="props.row.date"></span></q-tooltip> <q-tooltip><span v-text="props.row.date"></span></q-tooltip>
</i> </i>
<q-icon
@click="selectedPayment = props.row"
name="local_offer"
color="grey"
class="q-ml-sm cursor-pointer"
size="xs"
>
<q-tooltip>
<span v-text="$t('add_remove_labels')"></span>
</q-tooltip>
<q-popup-edit class="text-white q-pa-none">
<lnbits-label-selector
:labels="props.row.labels"
@update:labels="savePaymentLabels"
></lnbits-label-selector>
</q-popup-edit>
</q-icon>
<template v-for="label in g.user.extra.labels" :key="label.name">
<q-badge
v-if="props.row.labels.includes(label.name)"
@click="searchByLabels([label.name])"
:style="{
backgroundColor: label.color,
color: isLightColor(label.color) ? 'black' : 'white'
}"
class="q-ml-sm cursor-pointer"
size="xs"
dense
rounded
>
<span v-text="label.name"></span>
<q-tooltip>
<span v-text="label.description || label.name"></span>
</q-tooltip>
</q-badge>
</template>
</q-td> </q-td>
<q-td <q-td
auto-width auto-width

View file

@ -6,8 +6,17 @@
<div class="row items-center justify-between q-gutter-xs"> <div class="row items-center justify-between q-gutter-xs">
<div class="col"> <div class="col">
<q-btn @click="updateAccount" unelevated color="primary"> <q-btn @click="updateAccount" unelevated color="primary">
<q-badge
v-if="isUserTouched"
color="negative"
size="xs"
floating
></q-badge>
<span v-text="$t('update_account')"></span> <span v-text="$t('update_account')"></span>
</q-btn> </q-btn>
<q-badge v-if="isUserTouched" class="q-ml-sm" color="primary">
<span v-text="$t('must_save')"></span>
</q-badge>
</div> </div>
</div> </div>
</div> </div>
@ -20,6 +29,7 @@
<q-card> <q-card>
<q-splitter> <q-splitter>
<template v-slot:before> <template v-slot:before>
<!-- todo: small screen as in settings -->
<q-tabs v-model="tab" vertical active-color="primary"> <q-tabs v-model="tab" vertical active-color="primary">
<q-tab <q-tab
name="user" name="user"
@ -72,6 +82,16 @@
><span v-text="$t('assets')"></span ><span v-text="$t('assets')"></span
></q-tooltip> ></q-tooltip>
</q-tab> </q-tab>
<q-tab
name="labels"
icon="local_offer"
:label="$q.screen.gt.sm ? $t('labels') : ''"
@update="val => (tab = val.name)"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('labels')"></span
></q-tooltip>
</q-tab>
</q-tabs> </q-tabs>
</template> </template>
<template v-slot:after> <template v-slot:after>
@ -1027,6 +1047,92 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="labels">
<q-card-section>
<div class="row">
<div class="col-md-2 col-sm-12">
<q-btn
@click="openAddLabelDialog()"
:label="$t('add_label')"
color="primary"
class="full-width"
></q-btn>
</div>
<div class="col-md-1 col-sm-12"></div>
<div class="col-md-9 col-sm-12">
<q-input
:label="$t('search')"
dense
class="full-width q-pb-xl"
v-model="labelsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="labelsTable.search !== ''"
name="close"
@click="labelsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-table
:rows="user.extra.labels"
:columns="labelsTable.columns"
v-model:pagination="labelsTable.pagination"
:loading="labelsTable.loading"
row-key="name"
:filter="labelsTable.search"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="actions" :props="props">
<q-btn
@click="openEditLabelDialog(props.row)"
dense
flat
icon="edit"
color="primary"
></q-btn>
<q-btn
@click="deleteUserLabel(props.row)"
dense
flat
icon="delete"
color="negative"
class="q-ml-md"
></q-btn>
</q-td>
<q-td key="name" :props="props">
<span v-text="props.row.name"></span>
</q-td>
<q-td key="description" :props="props">
<span v-text="props.row.description"></span>
</q-td>
<q-td key="color" :props="props">
<q-badge
class="q-pa-sm"
:style="{
backgroundColor: props.row.color,
color: 'white'
}"
>
<span v-text="props.row.color"></span>
</q-badge>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-tab-panel>
</q-tab-panels> </q-tab-panels>
</q-scroll-area> </q-scroll-area>
</template> </template>
@ -1052,11 +1158,6 @@
</div> </div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn
@click="runPasswordGuardedFunction()"
:label="$t('ok')"
color="primary"
></q-btn>
<q-btn <q-btn
v-close-popup v-close-popup
flat flat
@ -1064,6 +1165,11 @@
class="q-ml-auto" class="q-ml-auto"
v-text="$t('cancel')" v-text="$t('cancel')"
></q-btn> ></q-btn>
<q-btn
@click="runPasswordGuardedFunction()"
:label="$t('ok')"
color="primary"
></q-btn>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -1167,4 +1273,81 @@
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="labelsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('label')"></strong>
<div class="row q-mt-md q-col-gutter-md">
<div class="col-12">
<q-input
v-model="labelsDialog.data.name"
dense
filled
:label="$t('name')"
>
</q-input>
</div>
<div class="col-12">
<q-input
v-model="labelsDialog.data.description"
dense
filled
type="textarea"
rows="3"
:label="$t('description')"
>
</q-input>
</div>
<div class="col-12">
<q-input
v-model="labelsDialog.data.color"
filled
dense
class="my-input"
>
<template v-slot:append>
<q-icon name="colorize" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-color
v-model="labelsDialog.data.color"
default-view="palette"
/>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
<q-btn
v-if="
user.extra.labels.some(
label => label.name === labelsDialog.data.name
)
"
@click="updateUserLabel()"
:disable="!labelsDialog.data.name || !labelsDialog.data.color"
:label="$t('update_label')"
color="primary"
></q-btn>
<q-btn
v-else
@click="addUserLabel()"
:disable="!labelsDialog.data.name || !labelsDialog.data.color"
:label="$t('add_label')"
color="primary"
></q-btn>
</div>
</q-card>
</q-dialog>
</template> </template>

View file

@ -136,6 +136,7 @@
"js/components/lnbits-manage-wallet-list.js", "js/components/lnbits-manage-wallet-list.js",
"js/components/lnbits-language-dropdown.js", "js/components/lnbits-language-dropdown.js",
"js/components/lnbits-payment-list.js", "js/components/lnbits-payment-list.js",
"js/components/lnbits-label-selector.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/data-fields.js", "js/components/data-fields.js",
"js/components.js", "js/components.js",

View file

@ -1,12 +1,16 @@
import hashlib import hashlib
from json import JSONDecodeError from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest import pytest
import shortuuid
from pytest_mock.plugin import MockerFixture from pytest_mock.plugin import MockerFixture
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.models import CreateInvoice, Payment from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.models.users import Account, UserExtra, UserLabel
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import api_payment from lnbits.core.views.payment_api import api_payment
from lnbits.fiat.base import FiatInvoiceResponse from lnbits.fiat.base import FiatInvoiceResponse
from lnbits.settings import Settings from lnbits.settings import Settings
@ -789,3 +793,176 @@ async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "value_error.url.scheme" in response.json()["detail"] assert "value_error.url.scheme" in response.json()["detail"]
################################ Labels ################################
@pytest.mark.anyio
async def test_api_search_payment_labels(client):
tiny_id = shortuuid.uuid()[:8]
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"u{tiny_id}",
extra=UserExtra(
labels=[
UserLabel(name="label A", color="#FF0000"),
UserLabel(name="label B", color="#00FF00"),
]
),
)
)
assert len(user.extra.labels) == 2
adminkey = user.wallets[0].adminkey
payments_headers = {
"X-Api-Key": adminkey,
"Content-type": "application/json",
}
payment_count = 10
await _create_some_payments(payment_count, client, payments_headers)
# search payments by label A
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 2
for payment in data["data"]:
assert "label A" in payment["labels"]
# search payments by label B
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label B"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 3
for payment in data["data"]:
assert "label B" in payment["labels"]
# search payments by label C
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label C"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 5
for payment in data["data"]:
assert "label C" in payment["labels"]
# search payments by label A and B
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A", "label B"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 6
for payment in data["data"]:
assert "label A" in payment["labels"]
assert "label B" in payment["labels"]
# search payments for random label D (no payments)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label D"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == 0
# search payments with no label filter (all payments)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": []},
headers=payments_headers,
)
assert response.is_success
all_payments = response.json()
assert all_payments["total"] == payment_count
no_label_a_payment = next(
(
payment
for payment in all_payments["data"]
if "label A" not in payment["labels"]
),
None,
)
assert no_label_a_payment is not None
payment_hash = no_label_a_payment["payment_hash"]
response = await client.put(
f"/api/v1/payments/{payment_hash}/labels",
headers=payments_headers,
json={"labels": ["label A"]},
)
# search payments by label A after update
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 2 + 1 # one more after update
for payment in data["data"]:
assert "label A" in payment["labels"]
# remove label A from all payments
for payment in all_payments["data"]:
payment_hash = payment["payment_hash"]
response = await client.put(
f"/api/v1/payments/{payment_hash}/labels",
headers=payments_headers,
json={"labels": []},
)
# search payments by label A (none should have it now)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == 0
async def _create_some_payments(payment_count: int, client, payments_headers):
payment_count = 10
for index in range(1, payment_count + 1):
labels = []
if index % 2 == 0:
labels.append("label A")
if index % 3 == 0:
labels.append("label B")
if index % 5 == 0:
# User does not have this label, but will be added to the payment.
labels.append("label C")
response = await client.post(
"/api/v1/payments",
headers=payments_headers,
json={
"out": False,
"amount": 1000 + index,
"memo": f"payment {index}",
"labels": labels,
},
)
assert response.is_success
data = response.json()
assert data["labels"] == labels
return payment_count

View file

@ -24,7 +24,9 @@ from lnbits.core.models.users import (
EndpointAccess, EndpointAccess,
LoginUsr, LoginUsr,
UpdateAccessControlList, UpdateAccessControlList,
UpdateUser,
UserAcls, UserAcls,
UserLabel,
) )
from lnbits.core.services.users import create_user_account from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_reset_password from lnbits.core.views.user_api import api_users_reset_password
@ -1999,3 +2001,48 @@ async def test_api_delete_user_api_token_missing_token_id(
json=delete_token_request.dict(), json=delete_token_request.dict(),
) )
assert response.status_code == 200, "Does noting if token not found." assert response.status_code == 200, "Does noting if token not found."
################################ Labels ################################
@pytest.mark.anyio
async def test_api_update_user_labels(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
user = await create_user_account()
assert user.extra.labels == []
user.extra.labels = [
UserLabel(name="label 01", color="#FF0000"),
UserLabel(name="label 02", color="#00FF00"),
]
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
assert data.extra
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 2
assert user_data["extra"]["labels"][0]["name"] == "label 01"
assert user_data["extra"]["labels"][0]["color"] == "#FF0000"
assert user_data["extra"]["labels"][1]["name"] == "label 02"
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
data.extra.labels = []
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 0
json_data = data.dict()
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=json_data
)
assert response.status_code == 400
data = response.json()
assert (
"""string does not match regex "([A-Za-z0-9 ._-]{1,100}$)""" in data["detail"]
)

View file

@ -16,6 +16,7 @@ from lnbits.core.crud import (
get_payment, get_payment,
update_payment, update_payment,
) )
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models import Account, CreateInvoice, PaymentState, User from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.models.users import UpdateSuperuserPassword from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account, update_wallet_balance from lnbits.core.services import create_user_account, update_wallet_balance
@ -114,6 +115,10 @@ async def user_alan():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def admin_user(): async def admin_user():
username = "admin" username = "admin"
account = await get_account_by_username(username)
if account:
return await get_user_from_account(account)
account = Account( account = Account(
id=ADMIN_USER_ID, id=ADMIN_USER_ID,
email=f"{username}@lnbits.com", email=f"{username}@lnbits.com",