Compare commits

..
Author SHA1 Message Date
dni ⚡ 2320018302 fixup! 2025-11-26 14:26:16 +01:00
dni ⚡ 0dc0725a9b fix: change button label on wallet 2025-11-26 14:25:21 +01:00
dni ⚡andGitHub 8c77f75cf1 chore: move map payment into lnbits-payment-list (#3572) 2025-11-26 13:27:54 +01:00
dni ⚡andGitHub 21505471d5 fix: mapping of user was done each time component was initialised (#3573) 2025-11-26 13:21:36 +01:00
dni ⚡andGitHub baa9a35773 fix: cleanup paymentEvents, simplify websockets (#3570) 2025-11-26 13:11:27 +01:00
ArcandGitHub 7f114ddcc0 fix: better naming (#3577) 2025-11-26 11:54:02 +00:00
dni ⚡andGitHub 92aad20dd7 fix: temporarly removed utils but used it for logout (#3574) 2025-11-26 11:20:01 +00:00
dni ⚡andGitHub 0f4ae5da86 feat: create wallet if user does not have one (#3566) 2025-11-25 16:18:45 +01:00
dni ⚡andGitHub 7a796c6510 chore: refactor windowMixin, init-app.js, lnbits-theme (#3569) 2025-11-25 14:31:17 +01:00
0910687328 [perf] pending payments check (#3565)
Co-authored-by: dni  <office@dnilabs.com>
2025-11-25 14:09:57 +02:00
dni ⚡andGitHub 33e2fc2ea8 feat: move wallet.html to vue component. FINAL DynamicComponent pr (#3559) 2025-11-25 11:45:53 +01:00
DoktorShiftandGitHub 0c6e8394c8 Update README.md with new TipJar badges and links (#3563) 2025-11-25 09:40:50 +02:00
Vlad StanandGitHub d55e2a0e1f [fix] user sorting performance (#3561) 2025-11-25 09:16:35 +02:00
dni ⚡andGitHub 148ba9d275 fix: robots.txt containing newline and whitespaces (#3560) 2025-11-24 16:26:21 +01:00
Tiago VasconcelosandGitHub d2ca774f6f fix: hide splitter on mobile (#3558) 2025-11-24 13:23:19 +02:00
dni ⚡andGitHub 233398b512 refactor: lnbits-wallet-extra the expandables in the sidebar (#3550) 2025-11-24 10:44:57 +01:00
dni ⚡andGitHub 152c1dbb74 feat: footer remove site_title condition (#3557) 2025-11-24 10:38:17 +01:00
43 changed files with 1672 additions and 1710 deletions
+4 -5
View File
@@ -5,8 +5,9 @@
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -77,13 +78,11 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
[docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
+8 -3
View File
@@ -4,7 +4,7 @@ from typing import Any
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.crud.wallets import create_wallet, get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
@@ -180,8 +180,13 @@ async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
async def get_user_from_account(
account: Account, conn: Connection | None = None
) -> User | None:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
extensions = await get_user_active_extensions_ids(account.id, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User(
id=account.id,
email=account.email,
+8 -7
View File
@@ -237,7 +237,7 @@ class AccountOverview(Account):
class AccountFilters(FilterModel):
__search_fields__ = [
"user",
"id",
"email",
"username",
"pubkey",
@@ -245,17 +245,18 @@ class AccountFilters(FilterModel):
"wallet_id",
]
__sort_fields__ = [
"balance_msat",
"id",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
"pubkey",
"external_id",
"created_at",
"updated_at",
]
email: str | None = None
user: str | None = None
id: str | None = None
username: str | None = None
email: str | None = None
pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None
File diff suppressed because it is too large Load Diff
+4 -61
View File
@@ -1,16 +1,14 @@
from hashlib import sha256
from http import HTTPStatus
from pathlib import Path
from typing import Annotated
from urllib.parse import urlencode, urlparse
import httpx
from fastapi import Cookie, Depends, Query, Request
from fastapi import Depends, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import url_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
@@ -25,12 +23,7 @@ from lnbits.decorators import (
from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings
from ...utils.exchange_rates import allowed_currencies
from ..crud import (
create_wallet,
get_user,
get_wallet,
)
from ..crud import get_user
generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False
@@ -42,60 +35,9 @@ async def favicon():
return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get(
"/wallet",
response_class=HTMLResponse,
description="show wallet page",
)
async def get_user_wallet(
request: Request,
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
user: User = Depends(check_user_exists),
wal: UUID4 | None = Query(None),
):
if wal:
wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user.wallets.append(wallet)
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet = await get_wallet(lnbits_last_active_wallet)
else:
wallet = user.wallets[0]
if not wallet or wallet.deleted:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found",
)
if wallet.user != user.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your wallet.",
)
context = {
"user": user.json(),
"wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
}
return template_renderer().TemplateResponse(
request,
"core/wallet.html",
{**context, "ajax": _is_ajax_request(request)},
)
@generic_router.get("/robots.txt", response_class=HTMLResponse)
async def robots():
data = """
User-agent: *
Disallow: /
"""
data = "User-agent: *\nDisallow: /"
return HTMLResponse(content=data, media_type="text/plain")
@@ -254,6 +196,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/payments")
@generic_router.get("/wallet")
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
+6
View File
@@ -179,12 +179,18 @@ async def api_payments_daily_stats(
)
async def api_payments_paginated(
key_info: WalletTypeInfo = Depends(require_invoice_key),
recheck_pending: bool = Query(
False, description="Force check and update of pending payments."
),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
page = await get_payments_paginated(
wallet_id=key_info.wallet.id,
filters=filters,
)
if not recheck_pending:
return page
for payment in page.data:
if payment.pending:
await update_pending_payment(payment)
+29 -15
View File
@@ -235,11 +235,14 @@ class Connection(Compat):
table_name: if provided some optimisations can be applied.
"""
if table_name and not _valid_sql_name(table_name):
raise ValueError(f"Invalid table name: '{table_name}'.")
if not filters:
filters = Filters()
if table_name:
if not _valid_sql_name(table_name):
raise ValueError(f"Invalid table name: '{table_name}'.")
filters.set_table_name(table_name)
clause = filters.where(where)
parsed_values = filters.values(values)
@@ -491,6 +494,7 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
table_name: str | None = None
field: str
op: Operator = Operator.EQ
model: type[TFilterModel] | None
@@ -518,7 +522,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__:
compare_field = model.__fields__[field]
values: dict = {}
if op in {Operator.EVERY, Operator.ANY}:
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
@@ -533,20 +537,23 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property
def statement(self) -> str:
prefix = f"{self.table_name}." if self.table_name else ""
stmt = []
for key in self.values.keys() if self.values else []:
clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime:
if self.model and self.model.__fields__[self.field].type_ == datetime:
placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(f":{key}")
else:
stmt.append(f"{clean_key} {self.op.as_sql} :{key}")
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
if self.op == Operator.EVERY:
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
elif self.op == Operator.EVERY:
statement = " AND ".join(stmt)
else:
statement = " OR ".join(stmt)
return f"({statement})"
@@ -563,13 +570,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
search: str | None = None
offset: int | None = None
limit: int | None = None
limit: int | None = 10
sortby: str | None = None
direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = None
table_name: str | None = None
@root_validator(pre=True)
def validate_sortby(cls, values):
sortby = values.get("sortby")
@@ -584,8 +592,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str:
stmt = ""
if self.limit:
stmt += f"LIMIT {self.limit} "
self.limit = self.limit or 10
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
return stmt
@@ -611,7 +619,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
def order_by(self) -> str:
if self.sortby:
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
prefix = f"{self.table_name}." if self.table_name else ""
return f"ORDER BY {prefix}{self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: dict | None = None) -> dict:
@@ -631,6 +640,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
values["search"] = f"%{self.search.lower()}%"
return values
def set_table_name(self, table_name: str) -> None:
self.table_name = table_name
for page_filter in self.filters:
page_filter.table_name = table_name
class DbJsonEncoder(json.JSONEncoder):
def default(self, o):
+3
View File
@@ -18,6 +18,7 @@ from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.utils.exchange_rates import currencies
from .db import FilterModel
@@ -109,6 +110,8 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
"LNBITS_TELEGRAM_CONFIGURED": settings.is_telegram_notifications_configured(),
"LNBITS_EXT_BUILDER": settings.lnbits_extensions_builder_activate_non_admins,
"LNBITS_CURRENCIES": list(currencies.keys()),
"LNBITS_ALLOWED_CURRENCIES": settings.lnbits_allowed_currencies,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -716,11 +716,14 @@ window.localisation.en = {
add_label: 'Add Label',
label: 'Label',
labels: 'Labels',
label_filter: 'Label Filter',
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'
color: 'Color',
sort: 'Sort',
sort_by: 'Sort by'
}
+6 -67
View File
@@ -2,18 +2,6 @@ window.LNbits = {
g: window.g,
utils: window._lnbitsUtils,
api: window._lnbitsApi,
events: {
onInvoicePaid(wallet, cb) {
ws = new WebSocket(`${websocketUrl}/${wallet.inkey}`)
ws.onmessage = ev => {
const data = JSON.parse(ev.data)
if (data.payment) {
cb(data)
}
}
return ws.onclose
}
},
map: {
user(data) {
const obj = {
@@ -27,16 +15,12 @@ window.LNbits = {
extra: data.extra ?? {}
}
const mapWallet = this.wallet
obj.wallets = obj.wallets
.map(obj => {
return mapWallet(obj)
})
.sort((a, b) => {
if (a.extra.pinned !== b.extra.pinned) {
return a.extra.pinned ? -1 : 1
}
return a.name.localeCompare(b.name)
})
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
if (a.extra.pinned !== b.extra.pinned) {
return a.extra.pinned ? -1 : 1
}
return a.name.localeCompare(b.name)
})
obj.walletOptions = obj.wallets.map(obj => {
return {
label: [obj.name, ' - ', obj.id.substring(0, 5), '...'].join(''),
@@ -77,51 +61,6 @@ window.LNbits = {
newWallet.url = `/wallet?&wal=${data.id}`
newWallet.storedPaylinks = data.stored_paylinks.links
return newWallet
},
payment(data) {
obj = {
checking_id: data.checking_id,
status: data.status,
amount: data.amount,
fee: data.fee,
memo: data.memo,
time: data.time,
bolt11: data.bolt11,
preimage: data.preimage,
payment_hash: data.payment_hash,
expiry: data.expiry,
extra: data.extra ?? {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
fiat_amount: data.fiat_amount,
fiat_currency: data.fiat_currency,
labels: data.labels
}
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = moment.utc(data.created_at).local().fromNow()
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag
obj.fsat = new Intl.NumberFormat(window.i18n.global.locale).format(
obj.sat
)
obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0
obj.isPending = obj.status === 'pending'
obj.isPaid = obj.status === 'success'
obj.isFailed = obj.status === 'failed'
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
try {
obj.details = JSON.parse(data.extra?.details || '{}')
} catch {
obj.details = {extraDetails: data.extra?.details}
}
return obj
}
}
}
@@ -1,13 +1,5 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server',
mixins: [window.windowMixin],
data() {
return {
currencies: []
}
},
async created() {
this.currencies = await LNbits.api.getCurrencies()
}
mixins: [window.windowMixin]
})
+1 -4
View File
@@ -9,10 +9,7 @@ window.app.component('lnbits-footer', {
return `${this.SITE_TITLE}, ${this.SITE_TAGLINE}`
},
showFooter() {
return (
this.SITE_TITLE == 'LNbits' &&
this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
)
return this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
}
}
})
@@ -1,6 +1,6 @@
window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list',
props: ['update', 'lazy', 'wallet', 'paymentFilter'],
props: ['wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() {
return {
@@ -147,6 +147,49 @@ window.app.component('lnbits-payment-list', {
}
},
methods: {
mapPayment(data) {
const obj = {
checking_id: data.checking_id,
status: data.status,
amount: data.amount,
fee: data.fee,
memo: data.memo,
time: data.time,
bolt11: data.bolt11,
preimage: data.preimage,
payment_hash: data.payment_hash,
expiry: data.expiry,
extra: data.extra ?? {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
fiat_amount: data.fiat_amount,
fiat_currency: data.fiat_currency,
labels: data.labels
}
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = moment.utc(data.created_at).local().fromNow()
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag
obj.fsat = new Intl.NumberFormat(window.i18n.global.locale).format(
obj.sat
)
obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0
obj.isPending = obj.status === 'pending'
obj.isPaid = obj.status === 'success'
obj.isFailed = obj.status === 'failed'
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
try {
obj.details = JSON.parse(data.extra?.details || '{}')
} catch {
obj.details = {extraDetails: data.extra?.details}
}
return obj
},
searchByDate() {
if (typeof this.searchDate === 'string') {
this.searchDate = {
@@ -195,9 +238,8 @@ window.app.component('lnbits-payment-list', {
.then(response => {
this.paymentsTable.loading = false
this.paymentsTable.pagination.rowsNumber = response.data.total
this.payments = response.data.data.map(obj => {
return LNbits.map.payment(obj)
})
this.payments = response.data.data.map(this.mapPayment)
this.recheckPendingPayments()
})
.catch(err => {
this.paymentsTable.loading = false
@@ -215,9 +257,7 @@ window.app.component('lnbits-payment-list', {
.then(response => {
this.paymentsTable.loading = false
this.paymentsTable.pagination.rowsNumber = response.data.total
this.payments = response.data.data.map(obj => {
return LNbits.map.payment(obj)
})
this.payments = response.data.data.map(this.mapPayment)
})
.catch(err => {
this.paymentsTable.loading = false
@@ -244,6 +284,41 @@ window.app.component('lnbits-payment-list', {
})
.catch(LNbits.utils.notifyApiError)
},
recheckPendingPayments() {
const pendingPayments = this.payments.filter(p => p.status === 'pending')
if (pendingPayments.length === 0) return
const params = [
'recheck_pending=true',
'checking_id[in]=' + pendingPayments.map(p => p.checking_id).join(',')
].join('&')
LNbits.api
.getPayments(this.currentWallet, params)
.then(response => {
let updatedPayments = 0
response.data.data.forEach(updatedPayment => {
if (updatedPayment.status !== 'pending') {
const index = this.payments.findIndex(
p => p.checking_id === updatedPayment.checking_id
)
if (index !== -1) {
this.payments.splice(index, 1, this.mapPayment(updatedPayment))
updatedPayments += 1
}
}
})
if (updatedPayments > 0) {
Quasar.Notify.create({
type: 'positive',
message: this.$t('payment_successful')
})
}
})
.catch(err => {
console.warn(err)
})
},
showHoldInvoiceDialog(payment) {
this.hodlInvoice.show = true
this.hodlInvoice.preimage = ''
@@ -287,7 +362,7 @@ window.app.component('lnbits-payment-list', {
}
const params = new URLSearchParams(query)
LNbits.api.getPayments(this.g.wallet, params).then(response => {
let payments = response.data.data.map(LNbits.map.payment)
let payments = response.data.data.map(this.mapPayment)
let columns = this.paymentsCSV.columns
if (detailed) {
@@ -423,23 +498,11 @@ window.app.component('lnbits-payment-list', {
this.fetchPayments()
}
},
lazy(newVal) {
if (newVal === true) this.fetchPayments()
},
update() {
this.fetchPayments()
},
'g.updatePayments'() {
this.fetchPayments()
},
'g.wallet': {
handler(newWallet) {
this.fetchPayments()
},
deep: true
}
},
created() {
if (this.lazy === undefined) this.fetchPayments()
this.fetchPayments()
}
})
@@ -1,6 +1,12 @@
window.app.component('lnbits-theme', {
mixins: [window.windowMixin],
watch: {
'g.walletFlip'(val) {
this.$q.localStorage.setItem('lnbits.walletFlip', val)
if (val === true && this.$q.screen.lt.md) {
this.g.visibleDrawer = false
}
},
'g.disclaimerShown'(val) {
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
},
@@ -0,0 +1,80 @@
window.app.component('lnbits-wallet-extra', {
template: '#lnbits-wallet-extra',
mixins: [window.windowMixin],
props: ['chartConfig'],
data() {
return {}
},
methods: {
handleSendLnurl(lnurl) {
this.$emit('send-lnurl', lnurl)
},
updateWallet(wallet) {
this.$emit('update-wallet', wallet)
},
handleFiatTracking() {
this.g.fiatTracking = !this.g.fiatTracking
if (!this.g.fiatTracking) {
this.g.isFiatPriority = false
this.g.wallet.currency = ''
this.updateWallet({currency: ''})
} else {
this.updateWallet({currency: this.g.wallet.currency})
this.updateFiatBalance()
}
},
deleteWallet() {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.api
.deleteWallet(this.g.wallet)
.then(_ => {
Quasar.Notify.create({
timeout: 3000,
message: `Wallet deleted!`,
spinner: true
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
updateFiatBalance() {
// set rate from local storage to avoid clunky api calls
if (
this.$q.localStorage.getItem(
'lnbits.exchangeRate.' + this.g.wallet.currency
)
) {
this.g.exchangeRate = this.$q.localStorage.getItem(
'lnbits.exchangeRate.' + this.g.wallet.currency
)
this.g.fiatBalance =
(this.g.exchangeRate / 100000000) * this.g.wallet.sat
}
LNbits.api
.request('GET', `/api/v1/rate/` + this.g.wallet.currency, null)
.then(response => {
this.g.fiatBalance =
(response.data.price / 100000000) * this.g.wallet.sat
this.g.exchangeRate = response.data.price.toFixed(2)
this.g.fiatTracking = true
this.$q.localStorage.set(
'lnbits.exchangeRate.' + this.g.wallet.currency,
this.g.exchangeRate
)
})
.catch(e => console.error(e))
}
},
created() {
if (this.g.wallet.currency !== '' && this.g.isSatsDenomination) {
this.g.fiatTracking = true
this.updateFiatBalance()
} else {
this.g.fiatTracking = false
}
}
})
+3 -2
View File
@@ -18,18 +18,19 @@ window.g = Vue.reactive({
fiatBalance: 0,
exchangeRate: 0,
fiatTracking: false,
wallets: [],
payments: [],
walletEventListeners: [],
showNewWalletDialog: false,
newWalletType: 'lightning',
updatePayments: false,
updatePaymentsHash: '',
currencies: WINDOW_SETTINGS.LNBITS_CURRENCIES ?? [],
allowedCurrencies: WINDOW_SETTINGS.LNBITS_ALLOWED_CURRENCIES ?? [],
locale: localStore('lnbits.lang', navigator.languages[1] ?? 'en'),
disclaimerShown: localStore('lnbits.disclaimerShown', false),
isFiatPriority: localStore('lnbits.isFiatPriority', false),
mobileSimple: localStore('lnbits.mobileSimple', true),
walletFlip: localStore('lnbits.walletFlip', false),
lastActiveWallet: localStore('lnbits.lastActiveWallet', null),
darkChoice: localStore('lnbits.darkMode', true),
themeChoice: localStore('lnbits.theme', WINDOW_SETTINGS.LNBITS_DEFAULT_THEME),
borderChoice: localStore(
+23 -153
View File
@@ -1,144 +1,12 @@
const DynamicComponent = {
props: {
fetchUrl: {
type: String,
required: true
},
scripts: {
type: Array,
default: () => []
const quasarConfig = {
config: {
loading: {
spinner: Quasar.QSpinnerBars
}
},
data() {
return {
keys: []
}
},
async mounted() {
await this.loadDynamicContent()
},
methods: {
async loadScript(src) {
return new Promise((resolve, reject) => {
const existingScript = document.querySelector(`script[src="${src}"]`)
if (existingScript) {
existingScript.remove()
}
const script = document.createElement('script')
script.src = src
script.async = true
script.onload = resolve
script.onerror = () =>
reject(new Error(`Failed to load script: ${src}`))
document.head.appendChild(script)
})
},
async loadDynamicContent() {
this.$q.loading.show()
try {
const cleanUrl = this.fetchUrl.split('#')[0]
//grab page content, need to be before loading scripts
const response = await fetch(cleanUrl, {
credentials: 'include',
headers: {
Accept: 'text/html',
'X-Requested-With': 'XMLHttpRequest'
}
})
const html = await response.text()
// load window variables
const parser = new DOMParser()
const htmlDocument = parser.parseFromString(html, 'text/html')
const inlineScript = htmlDocument.querySelector('#window-vars-script')
if (inlineScript) {
new Function(inlineScript.innerHTML)() // Execute the script
}
//load scripts defined in the route
await this.loadScript('/static/js/base.js')
for (const script of this.scripts) {
await this.loadScript(script)
}
//housecleaning, remove old component
const previousRouteName =
this.$router.currentRoute.value.meta.previousRouteName
if (
previousRouteName &&
window.app._context.components[previousRouteName]
) {
delete window.app._context.components[previousRouteName]
}
//load component logic
const logicKey = `${this.$route.name}PageLogic`
const componentLogic = window[logicKey]
if (!componentLogic) {
throw new Error(
`Component logic '${logicKey}' not found. Ensure it is defined in the script.`
)
}
//Add mixins
componentLogic.mixins = componentLogic.mixins || []
if (window.windowMixin) {
componentLogic.mixins.push(window.windowMixin)
}
//Build component
window.app.component(this.$route.name, {
...componentLogic,
template: html // Use the fetched HTML as the template
})
delete window[logicKey] //dont need this anymore
this.$forceUpdate()
} catch (error) {
console.error('Error loading dynamic content:', error)
} finally {
this.$q.loading.hide()
}
}
},
watch: {
$route(to, from) {
const validRouteNames = routes.map(route => route.name)
if (validRouteNames.includes(to.name)) {
this.$router.currentRoute.value.meta.previousRouteName = from.name
this.loadDynamicContent()
} else {
console.log(
`Route '${to.name}' is not valid. Leave this one to Fastapi.`
)
}
}
},
template: `
<component :is="$route.name"></component>
`
}
}
const routes = [
{
path: '/wallet',
name: 'Wallet',
component: DynamicComponent,
props: route => {
let fetchUrl = '/wallet'
if (Object.keys(route.query).length > 0) {
fetchUrl += '?'
for (const [key, value] of Object.entries(route.query)) {
fetchUrl += `${key}=${value}&`
}
fetchUrl = fetchUrl.slice(0, -1) // remove last &
}
return {
fetchUrl,
scripts: ['/static/js/wallet.js']
}
}
},
{
path: '/node',
name: 'Node',
@@ -159,6 +27,11 @@ const routes = [
name: 'Audit',
component: PageAudit
},
{
path: '/wallet',
name: 'Wallet',
component: PageWallet
},
{
path: '/wallets',
name: 'Wallets',
@@ -206,7 +79,19 @@ window.router = VueRouter.createRouter({
routes
})
window.i18n = new VueI18n.createI18n({
locale: window.g.locale,
fallbackLocale: 'en',
messages: window.localisation
})
window.app.mixin({
data() {
return {
g: window.g,
...WINDOW_SETTINGS
}
},
computed: {
isVueRoute() {
const currentPath = window.location.pathname
@@ -218,23 +103,8 @@ window.app.mixin({
})
window.app.use(VueQrcodeReader)
window.app.use(Quasar, {
config: {
loading: {
spinner: Quasar.QSpinnerBars
}
}
})
window.i18n = new VueI18n.createI18n({
locale: window.g.locale,
fallbackLocale: 'en',
messages: window.localisation
})
window.app.use(Quasar, quasarConfig)
window.app.use(window.i18n)
window.app.provide('g', g)
window.app.use(window.router)
window.app.component('DynamicComponent', DynamicComponent)
window.app.mount('#vue')
+24 -6
View File
@@ -85,10 +85,10 @@ window.PageUsers = {
sortable: false
},
{
name: 'user',
name: 'id',
align: 'left',
label: 'User Id',
field: 'user',
field: 'id',
sortable: false
},
@@ -119,7 +119,7 @@ window.PageUsers = {
align: 'left',
label: 'Balance',
field: 'balance_msat',
sortable: true
sortable: false
},
{
@@ -127,7 +127,7 @@ window.PageUsers = {
align: 'left',
label: 'Payments',
field: 'transaction_count',
sortable: true
sortable: false
},
{
@@ -135,16 +135,24 @@ window.PageUsers = {
align: 'left',
label: 'Last Payment',
field: 'last_payment',
sortable: true
sortable: false
}
],
pagination: {
sortBy: 'balance_msat',
sortBy: 'created_at',
rowsPerPage: 10,
page: 1,
descending: true,
rowsNumber: 10
},
sortFields: [
{name: 'id', label: 'User ID'},
{name: 'username', label: 'Username'},
{name: 'email', label: 'Email'},
{name: 'pubkey', label: 'Public Key'},
{name: 'created_at', label: 'Creation Date'},
{name: 'updated_at', label: 'Last Updated'}
],
search: null,
hideEmpty: true,
loading: false
@@ -198,6 +206,16 @@ window.PageUsers = {
})
.catch(LNbits.utils.notifyApiError)
},
sortByColumn(columnName) {
if (this.usersTable.pagination.sortBy === columnName) {
this.usersTable.pagination.descending =
!this.usersTable.pagination.descending
} else {
this.usersTable.pagination.sortBy = columnName
this.usersTable.pagination.descending = false
}
this.fetchUsers()
},
createUser() {
LNbits.api
.request('POST', '/users/api/v1/user', null, this.activeUser.data)
@@ -1,10 +1,8 @@
window.WalletPageLogic = {
window.PageWallet = {
template: '#page-wallet',
mixins: [window.windowMixin],
data() {
return {
origin: window.location.origin,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
parse: {
show: false,
invoice: null,
@@ -34,7 +32,7 @@ window.WalletPageLogic = {
amountMsat: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
units: ['sat', ...(this.currencies || [])],
unit: 'sat',
fiatProvider: '',
data: {
@@ -98,17 +96,6 @@ window.WalletPageLogic = {
this.parse.show = true
this.lnurlScan()
},
formatFiatAmount(amount, currency) {
this.update.currency = currency
this.formattedFiatAmount = LNbits.utils.formatCurrency(
amount.toFixed(2),
currency
)
this.formattedExchange = LNbits.utils.formatCurrency(
this.g.exchangeRate,
currency
)
},
msatoshiFormat(value) {
return LNbits.utils.formatSat(value / 1000)
},
@@ -118,9 +105,6 @@ window.WalletPageLogic = {
showCamera() {
this.parse.camera.show = true
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog() {
this.receive.show = true
this.receive.status = 'pending'
@@ -135,7 +119,6 @@ window.WalletPageLogic = {
: 'sat'
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
},
onReceiveDialogHide() {
if (this.hasNfc) {
@@ -154,7 +137,6 @@ window.WalletPageLogic = {
this.parse.data.internalMemo = null
this.parse.data.paymentChecker = null
this.parse.camera.show = false
this.focusInput('textArea')
},
closeParseDialog() {
setTimeout(() => {
@@ -420,7 +402,7 @@ window.WalletPageLogic = {
)
.then(response => {
dismissPaymentMsg()
this.updatePayments = !this.updatePayments
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
if (response.data.status == 'success') {
Quasar.Notify.create({
@@ -548,51 +530,6 @@ window.WalletPageLogic = {
LNbits.utils.notifyApiError(err)
})
},
deleteWallet() {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.api
.deleteWallet(this.g.wallet)
.then(_ => {
Quasar.Notify.create({
timeout: 3000,
message: `Wallet deleted!`,
spinner: true
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
updateFiatBalance(currency) {
// set rate from local storage to avoid clunky api calls
if (this.$q.localStorage.getItem('lnbits.exchangeRate.' + currency)) {
this.g.exchangeRate = this.$q.localStorage.getItem(
'lnbits.exchangeRate.' + currency
)
this.g.fiatBalance =
(this.g.exchangeRate / 100000000) * this.g.wallet.sat
this.formatFiatAmount(this.g.fiatBalance, currency)
}
if (currency && this.g.wallet.currency == currency) {
LNbits.api
.request('GET', `/api/v1/rate/` + currency, null)
.then(response => {
this.g.fiatBalance =
(response.data.price / 100000000) * this.g.wallet.sat
this.g.exchangeRate = response.data.price.toFixed(2)
this.g.fiatTracking = true
this.formatFiatAmount(this.g.fiatBalance, this.g.wallet.currency)
this.$q.localStorage.set(
'lnbits.exchangeRate.' + currency,
this.g.exchangeRate
)
})
.catch(e => console.error(e))
}
},
pasteToTextArea() {
this.$refs.textArea.focus()
navigator.clipboard.readText().then(text => {
@@ -694,36 +631,6 @@ window.WalletPageLogic = {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
},
swapBalancePriority() {
this.g.isFiatPriority = !this.g.isFiatPriority
this.receive.unit = this.g.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
},
handleFiatTracking() {
this.g.fiatTracking = !this.g.fiatTracking
if (!this.g.fiatTracking) {
this.g.isFiatPriority = false
this.update.currency = ''
this.g.wallet.currency = ''
this.updateWallet({currency: ''})
} else {
this.g.wallet.currency = this.update.currency
this.updateWallet({currency: this.update.currency})
this.updateFiatBalance(this.update.currency)
}
},
createdTasks() {
this.update.name = this.g.wallet.name
this.receive.units = ['sat', ...(window.currencies || [])]
if (this.g.wallet.currency != '' && LNBITS_DENOMINATION == 'sats') {
this.g.fiatTracking = true
this.updateFiatBalance(this.g.wallet.currency)
} else {
this.update.currency = ''
this.g.fiatTracking = false
}
}
},
created() {
@@ -734,15 +641,26 @@ window.WalletPageLogic = {
this.decodeRequest()
this.parse.show = true
}
this.createdTasks()
if (urlParams.has('wal')) {
const wallet = g.user.wallets.find(w => w.id === urlParams.get('wal'))
if (wallet) {
this.selectWallet(wallet)
}
} else {
const wallet = g.user.wallets.find(w => w.id === this.g.lastActiveWallet)
if (wallet) {
this.selectWallet(wallet)
} else if (g.user.wallets.length > 0) {
this.selectWallet(g.user.wallets[0])
}
}
},
watch: {
'g.updatePayments'(newVal, oldVal) {
'g.lastActiveWallet'(val) {
this.$q.localStorage.setItem('lnbits.lastActiveWallet', val)
},
'g.updatePayments'() {
this.parse.show = false
if (this.receive.paymentHash === this.g.updatePaymentsHash) {
this.receive.show = false
this.receive.paymentHash = null
}
if (
this.g.wallet.currency &&
this.$q.localStorage.getItem(
@@ -754,18 +672,36 @@ window.WalletPageLogic = {
)
this.g.fiatBalance =
(this.g.exchangeRate / 100000000) * this.g.wallet.sat
this.formatFiatAmount(this.g.fiatBalance, this.g.wallet.currency)
}
},
'g.wallet': {
handler() {
try {
this.createdTasks()
} catch (error) {
console.warn(`Chart creation failed: ${error}`)
}
},
deep: true
'g.wallet'() {
if (this.g.wallet.currency) {
this.g.fiatTracking = true
this.g.fiatBalance =
(this.g.exchangeRate / 100000000) * this.g.wallet.sat
} else {
this.g.fiatBalance = 0
this.g.fiatTracking = false
}
},
'g.isFiatPriority'() {
this.receive.unit = this.g.isFiatPriority ? this.g.wallet.currency : 'sat'
},
'g.fiatBalance'() {
this.formattedFiatAmount = LNbits.utils.formatCurrency(
this.g.fiatBalance.toFixed(2),
this.g.wallet.currency
)
},
'g.exchangeRate'() {
if (this.g.fiatTracking && this.g.wallet.currency) {
this.g.fiatBalance =
(this.g.exchangeRate / 100000000) * this.g.wallet.sat
this.formattedExchange = LNbits.utils.formatCurrency(
this.g.exchangeRate,
this.g.wallet.currency
)
}
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ window.PageWallets = {
}
},
goToWallet(walletId) {
window.location = `/wallet?wal=${walletId}`
this.$router.push({path: '/wallet', query: {wal: walletId}})
},
formattedFiatAmount(amount, currency) {
return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency)
+49 -63
View File
@@ -1,72 +1,67 @@
window.windowMixin = {
i18n: window.i18n,
data() {
return {
api: window._lnbitsApi,
utils: window._lnbitsUtils,
g: window.g,
...WINDOW_SETTINGS
}
},
methods: {
openNewWalletDialog(walletType = 'lightning') {
this.g.newWalletType = walletType
this.g.showNewWalletDialog = true
},
flipWallets(smallScreen) {
this.g.walletFlip = !this.g.walletFlip
if (this.g.walletFlip && smallScreen) {
this.g.visibleDrawer = false
onWebsocketMessage(ev) {
const data = JSON.parse(ev.data)
if (!data.payment) {
console.error('ws message no payment', data)
return
}
this.$q.localStorage.set('lnbits.walletFlip', this.g.walletFlip)
},
goToWallets() {
this.$router.push({
path: '/wallets'
// update sidebar wallet balances
this.g.user.wallets.forEach(w => {
if (w.id === data.payment.wallet_id) {
w.sat = data.wallet_balance
}
})
// if current wallet, update balance and payments
if (this.g.wallet.id === data.payment.wallet_id) {
this.g.wallet.sat = data.wallet_balance
// lnbits-payment-list is watching
this.g.updatePayments = !this.g.updatePayments
}
// NOTE: react only on incoming payments for now
if (data.payment.amount > 0) {
eventReaction(data.wallet_balance * 1000)
}
},
paymentEvents() {
this.g.walletEventListeners = this.g.walletEventListeners || []
let timeout
this.g.user.wallets.forEach(wallet => {
if (!this.g.walletEventListeners.includes(wallet.id)) {
this.g.walletEventListeners.push(wallet.id)
LNbits.events.onInvoicePaid(wallet, data => {
const walletIndex = this.g.user.wallets.findIndex(
w => w.id === wallet.id
const ws = new WebSocket(`${websocketUrl}/${wallet.inkey}`)
ws.onmessage = this.onWebsocketMessage
ws.onopen = () => console.log('ws connected for wallet', wallet.id)
// onclose and onerror can both happen on their own or together,
// so we add a clearTimeout to avoid multiple reconnections
ws.onclose = () => {
console.log('ws closed, reconnecting...', wallet.id)
this.g.walletEventListeners = this.g.walletEventListeners.filter(
id => id !== wallet.id
)
if (walletIndex !== -1) {
//needed for balance being deducted
let satBalance = data.wallet_balance
if (data.payment.amount < 0) {
satBalance = data.wallet_balance += data.payment.amount / 1000
}
//update the wallet
Object.assign(this.g.user.wallets[walletIndex], {
sat: satBalance,
msat: data.wallet_balance * 1000,
fsat: data.wallet_balance.toLocaleString()
})
//update the current wallet
if (this.g.wallet.id === data.payment.wallet_id) {
Object.assign(this.g.wallet, this.g.user.wallets[walletIndex])
//if on the wallet page and payment is incoming trigger the eventReaction
if (
data.payment.amount > 0 &&
window.location.pathname === '/wallet'
) {
eventReaction(data.wallet_balance * 1000)
}
}
}
this.g.updatePaymentsHash = data.payment.payment_hash
this.g.updatePayments = !this.g.updatePayments
})
clearTimeout(timeout)
timeout = setTimeout(this.paymentEvents, 5000)
}
ws.onerror = () => {
console.warn('ws error, reconnecting...', wallet.id)
this.g.walletEventListeners = this.g.walletEventListeners.filter(
id => id !== wallet.id
)
clearTimeout(timeout)
timeout = setTimeout(this.paymentEvents, 5000)
}
}
})
},
selectWallet(wallet) {
this.g.wallet = wallet
this.g.lastActiveWallet = wallet.id
this.g.updatePayments = !this.g.updatePayments
this.balance = parseInt(wallet.balance_msat / 1000)
const currentPath = this.$route.path
@@ -112,19 +107,10 @@ window.windowMixin = {
})
}
},
async created() {
if (window.user) {
this.g.user = Vue.reactive(window.LNbits.map.user(window.user))
}
if (window.wallet) {
this.g.wallet = Vue.reactive(window.LNbits.map.wallet(window.wallet))
}
if (window.extensions) {
this.g.extensions = Vue.reactive(window.extensions)
}
},
mounted() {
if (this.g.user) {
created() {
// map jinja variable once on pageload
if (window.user && !this.g.user) {
this.g.user = window.LNbits.map.user(window.user)
this.paymentEvents()
}
}
+2
View File
@@ -51,6 +51,7 @@
"js/pages/node.js",
"js/pages/node-public.js",
"js/pages/audit.js",
"js/pages/wallet.js",
"js/pages/wallets.js",
"js/pages/users.js",
"js/pages/account.js",
@@ -73,6 +74,7 @@
"js/components/lnbits-wallet-new.js",
"js/components/lnbits-wallet-share.js",
"js/components/lnbits-wallet-paylinks.js",
"js/components/lnbits-wallet-extra.js",
"js/components/lnbits-home-logos.js",
"js/components/lnbits-qrcode.js",
"js/components/lnbits-qrcode-lnurl.js",
+2 -1
View File
@@ -26,7 +26,8 @@ include('components/lnbits-label-selector.vue') %} {%
include('components/lnbits-wallet-api-docs.vue') %} {%
include('components/lnbits-wallet-share.vue') %} {%
include('components/lnbits-wallet-charts.vue') %} {%
include('components/lnbits-wallet-paylinks.vue') %}
include('components/lnbits-wallet-paylinks.vue') %} {%
include('components/lnbits-wallet-extra.vue') %}
<template id="lnbits-manage">
<q-list v-if="g.user" dense class="lnbits-drawer__q-list">
+2 -2
View File
@@ -15,7 +15,7 @@
multiple
:hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')"
:options="currencies"
:options="g.currencies"
></q-select>
</div>
<div class="col-12 col-md-6">
@@ -31,7 +31,7 @@
:options="
formData.lnbits_allowed_currencies?.length
? formData.lnbits_allowed_currencies
: currencies
: g.allowedCurrencies
"
></q-select>
</div>
@@ -8,7 +8,10 @@
>
<q-scroll-area style="height: 100%">
<q-item>
<q-item-section class="cursor-pointer" @click="goToWallets()">
<q-item-section
class="cursor-pointer"
@click="$router.push('/wallets')"
>
<q-item-label
:style="$q.dark.isActive ? 'color:rgba(255, 255, 255, 0.64)' : ''"
class="q-item__label q-item__label--header q-pa-none"
@@ -22,7 +25,7 @@
:icon="g.walletFlip ? 'view_list' : 'view_column'"
color="grey"
class=""
@click="flipWallets($q.screen.lt.md)"
@click="g.walletFlip = !g.walletFlip"
>
<q-tooltip
><span
@@ -97,7 +97,7 @@
round
color="primary"
icon="more_horiz"
@click="goToWallets()"
@click="$router.push('/wallets')"
>
<q-tooltip
><span
@@ -54,7 +54,7 @@
<q-item
v-if="g.user.hiddenWalletsCount > 0"
clickable
@click="goToWallets()"
@click="$router.push('/wallets')"
>
<q-item-section side>
<q-icon name="more_horiz" color="grey-5" size="md"></q-icon>
@@ -99,6 +99,7 @@
</q-list>
</q-card-section>
<q-expansion-item
v-if="!HIDE_API"
group="api"
dense
expand-separator
@@ -132,6 +133,7 @@
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
group="api"
dense
expand-separator
@@ -176,6 +178,7 @@
</q-card>
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
group="api"
dense
expand-separator
@@ -222,6 +225,7 @@
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
group="api"
dense
expand-separator
@@ -250,6 +254,7 @@
</q-card>
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
group="api"
dense
expand-separator
@@ -0,0 +1,220 @@
<template id="lnbits-wallet-extra">
<q-card class="wallet-extra">
<q-card-section class="q-pb-xs">
<div class="row items-center">
<q-avatar
size="lg"
:icon="g.wallet.extra.icon"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="g.wallet.extra.color"
>
</q-avatar>
<lnbits-wallet-icon @update-wallet="updateWallet"></lnbits-wallet-icon>
<div class="text-subtitle1 q-mt-none q-mb-none">
<span v-text="$t('wallet')"></span>
<strong><em v-text="g.wallet.name"></em></strong>
</div>
<q-space></q-space>
<div class="float-right">
<q-btn
@click="updateWallet({pinned: !g.wallet.extra.pinned})"
round
class="float-right"
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
text-color="black"
size="sm"
icon="push_pin"
style="transform: rotate(30deg)"
>
<q-tooltip><span v-text="$t('pin_wallet')"></span></q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<lnbits-wallet-paylinks
@send-lnurl="handleSendLnurl"
></lnbits-wallet-paylinks>
<q-separator></q-separator>
<lnbits-wallet-share></lnbits-wallet-share>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="phone_android"
:label="$t('access_wallet_on_mobile')"
>
<q-card>
<q-card-section>
You can connect to this wallet from a mobile app:
<ul>
<li>
Download
<a class="text-secondary" href="https://zeusln.app">Zeus</a>
or
<a class="text-secondary" href="https://bluewallet.io/"
>BlueWallet</a
>
from App Store or Google Play
</li>
<li>
Enable the
<a class="text-secondary" href="/lndhub">LndHub </a>
extension for this account
</li>
<li>
Scan the QR code in the
<a class="text-secondary" href="/lndhub">LndHub </a>
extensions with your mobile app
</li>
</ul>
</q-card-section>
<q-card-section>
Or you can access the wallet directly from your mobile browser
using:
<q-expansion-item
icon="mobile_friendly"
:label="$t('export_to_phone')"
>
<q-card>
<q-card-section>
<p
class="text-center"
v-text="$t('export_to_phone_desc')"
></p>
<lnbits-qrcode
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
></lnbits-qrcode>
</q-card-section>
</q-card>
</q-expansion-item>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="settings"
:label="$t('wallet_config')"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col-6">
<q-input filled v-model="g.wallet.name" label="Name" dense />
</div>
<div class="col-4 q-pl-sm">
<q-btn
:disable="!g.wallet.name.length"
unelevated
class="q-mt-xs full-width"
color="primary"
:label="$t('update_name')"
dense
@click="updateWallet({name: g.wallet.name})"
></q-btn>
</div>
<div class="col-2"></div>
</div>
</q-card-section>
<q-card-section v-if="g.isSatsDenomination">
<div class="row">
<div class="col-6">
<q-select
filled
dense
v-model="g.wallet.currency"
@change="updateWallet({currency: g.wallet.currency})"
type="text"
:disable="g.fiatTracking"
:options="g.allowedCurrencies"
:label="$t('currency_settings')"
></q-select>
</div>
<div class="col-4 q-pl-sm">
<q-btn
dense
color="primary"
class="q-mt-xs full-width"
@click="handleFiatTracking()"
:disable="g.wallet.currency == ''"
:label="g.fiatTracking ? 'Remove' : 'Add'"
></q-btn>
</div>
<div class="col-2">
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
class="float-right q-mb-lg"
to="/admin#exchange_providers"
><q-tooltip v-text="$t('exchange_providers')"></q-tooltip
></q-btn>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-6">
<p v-text="$t('delete_wallet_desc')"></p>
</div>
<div class="col-4 q-pl-sm">
<q-btn
unelevated
color="red-10"
class="full-width"
@click="deleteWallet()"
:label="$t('delete_wallet')"
></q-btn>
</div>
<div class="col-2"></div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="insights"
:label="$t('wallet_charts')"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
v-model="chartConfig.showBalanceChart"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
v-model="chartConfig.showBalanceInOutChart"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
v-model="chartConfig.showPaymentInOutChart"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<lnbits-wallet-api-docs></lnbits-wallet-api-docs>
</q-list>
</q-card-section>
</q-card>
</template>
+2 -17
View File
@@ -1,29 +1,14 @@
{% macro window_vars(user, wallet, extensions, extension_data) -%}
{% macro window_vars(user) -%}
<script id="window-vars-script">
window.extensions = JSON.parse('{{ EXTENSIONS | tojson | safe }}');
{% if extension_data %}
window.extension_data = {{ extension_data | tojson | safe }};
{% endif %}
{% if currencies %}
window.currencies = {{ currencies | tojson | safe }};
{% endif %}
{% if user %}
window.user = JSON.parse({{ user | tojson | safe }});
{% endif %}
{% if wallet %}
window.wallet = JSON.parse({{ wallet | tojson | safe }});
{% endif %}
</script>
<script>
//Needed for Vue to create the app on first load (although called on every page, its only loaded once)
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data() {
return {
updatePayments: false
}
}
mixins: [window.windowMixin]
})
</script>
{%- endmacro %}
+1 -1
View File
@@ -3,4 +3,4 @@ include('pages/audit.vue') %} {% include('pages/wallets.vue') %} {%
include('pages/users.vue') %} {% include('pages/admin.vue') %} {%
include('pages/account.vue') %} {% include('pages/extensions_builder.vue') %} {%
include('pages/extensions.vue') %} {% include('pages/first-install.vue') %} {%
include('pages/home.vue') %}
include('pages/home.vue') %} {% include('pages/wallet.vue') %}
+2 -2
View File
@@ -100,8 +100,8 @@
</q-select>
</div>
<q-splitter>
<template v-slot:before>
<q-splitter :separator-style="$q.screen.lt.md && 'display: none'">
<template v-slot:before v-if="$q.screen.gt.sm">
<q-tabs v-model="tab" vertical active-color="primary">
<q-tab
name="funding"
+38 -1
View File
@@ -515,7 +515,44 @@
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>
<q-btn-dropdown color="primary" icon="sort" flat dense>
<q-list>
<template
class="full-width"
v-for="column in usersTable.sortFields"
:key="column.name"
>
<q-item
@click="sortByColumn(column.name)"
clickable
v-ripple
v-close-popup
dense
>
<q-item-section>
<q-item-label lines="1" class="full-width"
><span v-text="column.label"></span
></q-item-label>
</q-item-section>
<q-item-section side>
<template
v-if="
usersTable.pagination.sortBy === column.name
"
>
<q-icon
v-if="usersTable.pagination.descending"
name="arrow_downward"
></q-icon>
<q-icon v-else name="arrow_upward"></q-icon>
</template>
</q-item-section>
</q-item>
</template>
</q-list>
</q-btn-dropdown>
</q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="
+876
View File
@@ -0,0 +1,876 @@
<template id="page-wallet">
<div class="row q-col-gutter-md" style="margin-bottom: 6rem">
<div class="col-12 col-md-7 q-gutter-y-md wallet-wrapper">
<q-card class="wallet-card">
<q-card-section>
<div class="row q-gutter-sm">
<div v-if="g.fiatTracking" class="col-auto">
<q-btn
@click="g.isFiatPriority = !g.isFiatPriority"
style="height: 50px"
class="q-mt-lg"
color="primary"
flat
dense
icon="swap_vert"
></q-btn>
</div>
<div class="col">
<div
v-if="!g.isFiatPriority || !g.fiatTracking"
class="column"
:class="{
'q-pt-sm': g.fiatTracking,
'q-pt-lg': !g.fiatTracking
}"
style="height: 100px"
>
<div class="col-7">
<div class="row">
<div class="col-auto">
<div class="text-h3 q-my-none full-width">
<strong
v-text="formatBalance(g.wallet.sat)"
class="text-no-wrap"
:style="{
fontSize: 'clamp(0.75rem, 10vw, 3rem)',
display: 'inline-block',
maxWidth: '100%'
}"
></strong>
</div>
</div>
<div class="col-auto">
<lnbits-update-balance
v-if="$q.screen.lt.lg"
:wallet_id="g.wallet.id"
:callback="updateBalanceCallback"
:small_btn="true"
></lnbits-update-balance>
</div>
</div>
</div>
<div class="col-2">
<div v-if="g.fiatTracking">
<span
class="text-h5 text-italic"
v-text="formattedFiatAmount"
style="opacity: 0.75"
></span>
</div>
</div>
</div>
<div
class="column"
v-if="g.isFiatPriority && g.fiatTracking"
:class="{
'q-pt-sm': g.fiatTracking,
'q-pt-lg': !g.fiatTracking
}"
style="height: 100px"
>
<div class="col-7">
<div class="row">
<div class="col-auto">
<div
v-if="g.fiatTracking"
class="text-h3 q-my-none text-no-wrap"
>
<strong v-text="formattedFiatAmount"></strong>
</div>
</div>
<div class="col-auto">
<lnbits-update-balance
v-if="$q.screen.lt.lg"
:wallet_id="g.wallet.id"
:callback="updateBalanceCallback"
:small_btn="true"
></lnbits-update-balance>
</div>
</div>
</div>
<div class="col-2">
<span
class="text-h5 text-italic"
style="opacity: 0.75"
v-text="formatBalance(g.wallet.sat)"
>
</span>
</div>
</div>
<div
class="absolute-right q-pa-md"
v-if="$q.screen.gt.md && g.fiatTracking && g.isSatsDenomination"
>
<div class="text-bold text-italic">BTC Price</div>
<span
class="text-bold text-italic"
v-text="formattedExchange"
></span>
</div>
<q-btn
v-if="$q.screen.lt.md"
@click="g.mobileSimple = !g.mobileSimple"
color="primary"
class="q-ml-xl absolute-right"
dense
size="sm"
style="height: 20px; margin-top: 75px"
flat
:icon="g.mobileSimple ? 'unfold_more' : 'unfold_less'"
:label="g.mobileSimple ? $t('more') : $t('less')"
></q-btn>
</div>
</div>
<div
v-if="!g.mobileSimple || !$q.screen.lt.md"
class="lnbits-wallet-buttons row q-gutter-md"
>
<div class="col">
<q-btn
unelevated
color="primary"
class="q-mr-md"
@click="showReceiveDialog"
:disable="!this.g.wallet.canReceivePayments"
:label="$t('receive')"
icon="file_download"
></q-btn>
<q-btn
unelevated
color="primary"
class="q-mr-md"
@click="showParseDialog"
:disable="!this.g.wallet.canSendPayments"
:label="$t('send')"
icon="file_upload"
></q-btn>
<q-btn
unelevated
color="secondary"
icon="qr_code_scanner"
:disable="
!this.g.wallet.canReceivePayments &&
!this.g.wallet.canSendPayments
"
@click="showCamera"
>
<q-tooltip
><span v-text="$t('camera_tooltip')"></span
></q-tooltip>
</q-btn>
<lnbits-update-balance
v-if="$q.screen.gt.md"
:wallet_id="this.g.wallet.id"
:callback="updateBalanceCallback"
:small_btn="false"
></lnbits-update-balance>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="wallet-card">
<q-card-section>
<lnbits-payment-list
:expand-details="expandDetails"
:payment-filter="paymentFilter"
></lnbits-payment-list>
</q-card-section>
</q-card>
</div>
<div
v-if="!g.mobileSimple || !$q.screen.lt.md"
class="col-12 col-md-5 q-gutter-y-md"
>
<lnbits-wallet-extra
@update-wallet="updateWallet"
@send-lnurl="handleSendLnurl"
:chart-config="chartConfig"
></lnbits-wallet-extra>
<q-card class="lnbits-wallet-ads" v-if="AD_SPACE_ENABLED">
<q-card-section class="text-subtitle1">
<span v-text="AD_SPACE_TITLE"></span>
<a :href="ad[0]" class="lnbits-ad" v-for="ad in g.ads">
<q-img class="q-mb-xs" v-if="$q.dark.isActive" :src="ad[1]"></q-img>
<q-img class="q-mb-xs" v-else :src="ad[2]"></q-img>
</a>
</q-card-section>
</q-card>
<lnbits-wallet-charts
:payment-filter="paymentFilter"
:chart-config="chartConfig"
></lnbits-wallet-charts>
</div>
</div>
<q-dialog v-model="receive.show" position="top" @hide="onReceiveDialogHide">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b v-text="receive.lnurl.domain"></b> is requesting an invoice:
</p>
<q-input
v-if="!g.isSatsDenomination"
filled
dense
v-model="receive.data.amount"
:label="$t('amount') + '(' + denomination + ') *'"
mask="#.##"
fill-mask="0"
reverse-fill-mask
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
<div v-else>
<div class="row">
<div class="col-10">
<q-select
filled
dense
v-model="receive.unit"
type="text"
:label="$t('unit')"
:options="receive.units"
></q-select>
</div>
<div class="col-2">
<q-btn
v-if="g.fiatTracking"
@click="g.isFiatPriority = !g.isFiatPriority"
class="float-right"
color="primary"
flat
dense
icon="swap_vert"
></q-btn>
</div>
</div>
<q-input
class="q-mt-md"
ref="setAmount"
filled
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
inputmode="numeric"
dense
v-model.number="receive.data.amount"
:label="$t('amount') + ' (' + receive.unit + ') *'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
</div>
<q-input
v-if="has_holdinvoice"
filled
dense
v-model="receive.data.payment_hash"
:label="$t('hold_invoice_payment_hash')"
></q-input>
<q-input
filled
dense
type="textarea"
rows="2"
v-model="receive.data.memo"
:label="$t('memo')"
>
<template v-if="receive.data.internalMemo === null" v-slot:append>
<q-icon
name="add_comment"
@click.stop.prevent="receive.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip>
</template>
</q-input>
<q-input
v-if="receive.data.internalMemo !== null"
autogrow
filled
dense
v-model="receive.data.internalMemo"
class="q-mb-lg"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_receive')"
:rules="[
val =>
!val || val.length <= 512 || 'Please use maximum 512 characters'
]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="receive.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input>
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
<q-list bordered dense class="rounded-borders">
<q-item-label dense header>
<span v-text="$t('select_payment_provider')"></span>
</q-item-label>
<q-separator></q-separator>
<q-item
:active="!receive.fiatProvider"
@click="receive.fiatProvider = ''"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar square>
<img src="/static/images/logos/lnbits.png" />
</q-avatar>
</q-item-section>
<q-item-section>
<span
v-text="$t('pay_with', {provider: 'Lightning Network'})"
></span>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
:active="receive.fiatProvider === 'stripe'"
@click="receive.fiatProvider = 'stripe'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar>
<img src="/static/images/stripe_logo.ico" />
</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
</q-item-section>
</q-item>
</q-list>
</div>
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span
v-if="receive.lnurl"
v-text="`${$t('withdraw_from')} ${receive.lnurl.domain}`"
></span>
<span v-else v-text="$t('create_invoice')"></span>
</q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
<q-spinner-bars
v-if="receive.status == 'loading'"
color="primary"
size="2.55em"
></q-spinner-bars>
</q-form>
</q-card>
<q-card
v-else-if="receive.paymentReq && receive.lnurl == null"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<lnbits-qrcode
v-if="receive.fiatPaymentReq"
:show-buttons="false"
:href="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
>
</lnbits-qrcode>
<lnbits-qrcode
v-else
:href="'lightning:' + receive.paymentReq"
:value="'lightning:' + receive.paymentReq"
>
</lnbits-qrcode>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>
</h3>
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span>
</h5>
<div v-if="!receive.fiatPaymentReq">
<q-chip v-if="hasNfc" outline square color="positive">
<q-avatar icon="nfc" color="positive" text-color="white"></q-avatar>
<span v-text="$t('nfc_supported')"></span>
</q-chip>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
<div class="column content-center text-center q-mb-md">
<div v-if="!g.isFiatPriority">
<h4 class="q-my-none text-bold">
<span v-text="formatBalance(parse.invoice.sat)"></span>
</h4>
</div>
<div v-else>
<h4
class="q-my-none text-bold"
v-text="parse.invoice.fiatAmount"
></h4>
</div>
<div class="q-my-md absolute">
<q-btn
v-if="g.fiatTracking"
@click="g.isFiatPriority = !g.isFiatPriority"
flat
dense
icon="swap_vert"
color="primary"
></q-btn>
</div>
<div v-if="g.fiatTracking">
<div v-if="g.isFiatPriority">
<h5 class="q-my-none text-bold">
<span v-text="formatBalance(parse.invoice.sat)"></span>
</h5>
</div>
<div v-else style="opacity: 0.75">
<div class="text-h5 text-italic">
<span
v-text="parse.invoice.fiatAmount"
style="opacity: 0.75"
></span>
</div>
</div>
</div>
</div>
<q-separator></q-separator>
<h6 class="text-center" v-text="parse.invoice.description"></h6>
<q-input
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class="q-mb-lg"
:rules="[
val =>
!val || val.length <= 512 || 'Please use maximum 512 characters'
]"
><template v-if="parse.data.internalMemo" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer" /></template
></q-input>
<q-list separator bordered dense class="q-mb-md">
<q-expansion-item expand-separator icon="info" label="Details">
<q-list separator>
<q-item>
<q-item-section>
<q-item-label v-text="$t('created')"></q-item-label>
<q-item-label
caption
v-text="parse.invoice.createdDate"
></q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label
caption
v-text="parse.invoice.createdDateFrom"
></q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label v-text="$t('expire_date')"></q-item-label>
<q-item-label
caption
v-text="parse.invoice.expireDate"
></q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label
caption
v-text="parse.invoice.expireDateFrom"
></q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label v-text="$t('payment_hash')"></q-item-label>
<q-item-label
caption
v-text="
`${parse.invoice.hash.slice(0, 12)}...${parse.invoice.hash.slice(-12)}`
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>
<q-icon
name="content_copy"
@click="copyText(parse.invoice.hash)"
size="1em"
color="grey"
class="cursor-pointer"
/>
</q-item-label>
<q-tooltip>
<span v-text="parse.invoice.hash"></span>
</q-tooltip>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label v-text="$t('Invoice')"></q-item-label>
<q-item-label
caption
v-text="
`${parse.invoice.bolt11.slice(0, 12)}...${parse.invoice.bolt11.slice(-12)}`
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>
<q-icon
name="content_copy"
@click="copyText(parse.invoice.bolt11)"
size="1em"
color="grey"
class="cursor-pointer"
/>
</q-item-label>
<q-tooltip>
<span v-text="parse.invoice.bolt11"></span>
</q-tooltip>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
</q-list>
<div v-if="canPay" class="row q-mt-lg">
<q-btn
unelevated
color="primary"
@click="payInvoice"
:label="$t('pay')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn
:label="$t('not_enough_funds')"
unelevated
disabled
color="yellow"
text-color="black"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</div>
<div v-else-if="parse.lnurlauth">
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be
shared with
<span v-text="parse.lnurlauth.domain"></span>.
</p>
<p>Your public key for <b v-text="parse.lnurlauth.domain"></b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
</p>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:label="$t('login')"
></q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
flat
color="grey"
class="q-ml-auto"
></q-btn>
</div>
</q-form>
</div>
<div v-else-if="parse.lnurlpay">
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b v-text="parse.lnurlpay.domain"></b> is requesting
<span v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></span>
<span v-text="denomination"></span>
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a
<span v-text="parse.lnurlpay.commentAllowed"></span>-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b v-text="parse.lnurlpay.targetUser || parse.lnurlpay.domain"></b>
is requesting <br />
between
<b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
<b v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></b>
<span v-text="denomination"></span>
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a
<span v-text="parse.lnurlpay.commentAllowed"></span>-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p
class="col text-justify text-italic"
v-text="parse.lnurlpay.description"
></p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col q-mb-lg">
<q-select
filled
dense
v-if="!parse.lnurlpay.fixed"
v-model="parse.data.unit"
type="text"
:label="$t('unit')"
:options="receive.units"
></q-select>
<br />
<q-input
ref="setAmount"
filled
dense
v-model.number="parse.data.amount"
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
:mask="parse.data.unit == 'sat' ? '#' : ''"
:step="parse.data.unit == 'sat' ? '1' : '0.01'"
fill-mask="0"
reverse-fill-mask
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay && parse.lnurlpay.fixed"
></q-input>
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model="parse.data.comment"
:type="
parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'
"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
><template
v-if="parse.data.internalMemo === null"
v-slot:append
>
<q-icon
name="add_comment"
@click.stop.prevent="parse.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip> </template
></q-input>
<br />
<q-input
v-if="parse.data.internalMemo !== null"
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class=""
:rules="[
val =>
!val ||
val.length <= 512 ||
'Please use maximum 512 characters'
]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Send</q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
flat
color="grey"
class="q-ml-auto"
></q-btn>
</div>
</q-form>
</div>
<div v-else>
<q-form
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="parse.data.request"
type="textarea"
:label="$t('paste_invoice_label')"
ref="textArea"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="parse.data.request == ''"
type="submit"
:label="$t('read')"
></q-btn>
<q-icon
name="content_paste"
color="grey"
class="q-mt-xs q-ml-sm q-mr-auto"
v-if="parse.copy.show"
@click="pasteToTextArea"
>
<q-tooltip>
<span v-text="$t('paste_from_clipboard')"></span>
</q-tooltip>
</q-icon>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn
:label="$t('cancel')"
@click="closeCamera"
flat
color="grey"
class="q-ml-auto"
>
</q-btn>
</div>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="parse.camera.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn
@click="closeCamera"
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<div
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
>
<q-tabs active-class="px-0" indicator-color="transparent" align="justify">
<q-tab
icon="file_download"
@click="showReceiveDialog"
:label="$t('receive')"
>
</q-tab>
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
</q-tab>
</q-tabs>
<q-btn
round
size="35px"
unelevated
icon="qr_code_scanner"
@click="showCamera"
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
>
</q-btn>
</div>
</template>
+2
View File
@@ -103,6 +103,7 @@
"js/pages/node.js",
"js/pages/node-public.js",
"js/pages/audit.js",
"js/pages/wallet.js",
"js/pages/wallets.js",
"js/pages/users.js",
"js/pages/account.js",
@@ -125,6 +126,7 @@
"js/components/lnbits-wallet-new.js",
"js/components/lnbits-wallet-share.js",
"js/components/lnbits-wallet-paylinks.js",
"js/components/lnbits-wallet-extra.js",
"js/components/lnbits-home-logos.js",
"js/components/lnbits-qrcode.js",
"js/components/lnbits-qrcode-lnurl.js",
+14 -1
View File
@@ -418,9 +418,22 @@ async def test_get_payments_paginated(client, inkey_fresh_headers_to, fake_payme
)
assert response.status_code == 200
paginated = response.json()
assert len(paginated["data"]) == 2
data = paginated["data"]
assert len(data) == 2
assert paginated["total"] == len(fake_data)
checking_id_list = [payment["checking_id"] for payment in data]
params = {"checking_id[in]": ",".join(checking_id_list)}
response = await client.get(
"/api/v1/payments/paginated",
params=params,
headers=inkey_fresh_headers_to,
)
data = response.json()["data"]
assert len(data) == 2
for payment in data:
assert payment["checking_id"] in checking_id_list
@pytest.mark.anyio
async def test_get_payments_history(client, inkey_fresh_headers_to, fake_payments):
-7
View File
@@ -25,13 +25,6 @@ async def test_get_wallet_with_user_and_wallet(client, to_user, to_wallet):
assert response.status_code == 200, f"{response.url} {response.status_code}"
# check GET /wallet: wrong wallet and user, expect 400
@pytest.mark.anyio
async def test_get_wallet_with_user_and_wrong_wallet(client, to_user):
response = await client.get("wallet", params={"usr": to_user.id, "wal": "1"})
assert response.status_code == 400, f"{response.url} {response.status_code}"
# check GET /extensions: extensions list
@pytest.mark.anyio
async def test_get_extensions(client, to_user):
+46 -1
View File
@@ -1,10 +1,12 @@
from typing import Any
from uuid import uuid4
import pytest
import shortuuid
from httpx import AsyncClient
from lnbits.core.models.users import User
from lnbits.core.models.users import Account, User
from lnbits.core.services.users import create_user_account
from lnbits.settings import Settings
from lnbits.utils.nostr import generate_keypair, hex_to_npub
@@ -465,3 +467,46 @@ async def test_create_user_invalid_npub(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 400
@pytest.mark.anyio
async def test_search_users(http_client: AsyncClient, superuser_token):
namespace_id = shortuuid.uuid()[:8]
users = []
user_count = 15
for index in range(user_count):
username = f"u_{namespace_id}_{index:03d}"
user = await create_user_account(
Account(
id=uuid4().hex,
username=username,
email=f"{username}@lnbits.com",
pubkey="",
external_id=None,
)
)
users.append(user)
create_resp = await http_client.get(
"/users/api/v1/user?sortby=id&direction=desc",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
create_resp = await http_client.get(
"/users/api/v1/user"
f"?sortby=username&direction=desc&username[like]=u_{namespace_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
data = create_resp.json()
assert data["total"] == user_count
assert data["data"][0]["username"] == users[user_count - 1].username
create_resp = await http_client.get(
"/users/api/v1/user" f"?sortby=username&direction=desc&id={users[0].id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
data = create_resp.json()
assert data["total"] == 1
assert data["data"][0]["username"] == users[0].username
+14 -9
View File
@@ -36,37 +36,42 @@ async def test_crud_get_payments(app):
await update_wallet_balance(wallet, -10)
wallet.balance_msat += -10 * 1000
payments = await get_payments(wallet_id=wallet.id)
filters = Filters(limit=100)
payments = await get_payments(wallet_id=wallet.id, filters=filters)
assert len(payments) == 22, "should return 22 successful payments"
payments = await get_payments(wallet_id=wallet.id, incoming=True)
payments = await get_payments(wallet_id=wallet.id, incoming=True, filters=filters)
assert len(payments) == 11, "should return 11 successful incoming payments"
await update_payments(payments)
payments = await get_payments(wallet_id=wallet.id, outgoing=True)
payments = await get_payments(wallet_id=wallet.id, outgoing=True, filters=filters)
assert len(payments) == 11, "should return 11 successful outgoing payments"
await update_payments(payments)
payments = await get_payments(wallet_id=wallet.id, pending=True)
payments = await get_payments(wallet_id=wallet.id, pending=True, filters=filters)
assert len(payments) == 4, "should return 4 pending payments"
# function signature should have Optional[bool] for complete and pending to make
# this distinction possible
payments = await get_payments(wallet_id=wallet.id, pending=False)
payments = await get_payments(wallet_id=wallet.id, pending=False, filters=filters)
assert len(payments) == 22, "should return all payments"
payments = await get_payments(wallet_id=wallet.id, complete=True, pending=True)
payments = await get_payments(
wallet_id=wallet.id, complete=True, pending=True, filters=filters
)
assert len(payments) == 20, "should return 4 pending and 16 complete payments"
payments = await get_payments(wallet_id=wallet.id, complete=True, outgoing=True)
payments = await get_payments(
wallet_id=wallet.id, complete=True, outgoing=True, filters=filters
)
assert (
len(payments) == 10
), "should return 8 complete outgoing payments and 2 pending outgoing payments"
payments = await get_payments(wallet_id=wallet.id)
payments = await get_payments(wallet_id=wallet.id, filters=filters)
assert len(payments) == 22, "should return all payments"
payments = await get_payments(wallet_id=wallet.id, complete=True)
payments = await get_payments(wallet_id=wallet.id, complete=True, filters=filters)
assert (
len(payments) == 18
), "should return 14 successful payment and 4 pending payments"
+38
View File
@@ -0,0 +1,38 @@
from uuid import uuid4
import pytest
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.crud.wallets import delete_wallet, get_wallets
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
@pytest.mark.anyio
async def test_get_user_from_account_is_wallet_created():
username = f"user_{uuid4().hex[:8]}"
account = Account(
id=uuid4().hex,
username=username,
email=f"{username}@lnbits.com",
)
account.hash_password("secret1234")
user = await create_user_account(account)
assert user is not None
assert (
len(user.wallets) == 1
), "A wallet should be created for the user if none exist"
await delete_wallet(user_id=account.id, wallet_id=user.wallets[0].id)
wallets = await get_wallets(account.id, deleted=False)
assert len(wallets) == 0, "User should have no wallets after deletion"
user = await get_user_from_account(account)
assert user is not None
assert (
len(user.wallets) == 1
), "A new wallet should be created for the user if none exist after deletion"
+6 -1
View File
@@ -9,6 +9,7 @@ from lnbits.core.crud.wallets import (
get_wallets,
update_wallet,
)
from lnbits.core.models.payments import PaymentFilters
from lnbits.core.models.users import User
from lnbits.core.models.wallets import (
Wallet,
@@ -29,6 +30,7 @@ from lnbits.core.services.wallets import (
reject_wallet_invitation,
update_wallet_share_permissions,
)
from lnbits.db import Filters
from lnbits.exceptions import InvoiceError, PaymentError
from tests.conftest import new_user
@@ -563,7 +565,10 @@ async def test_shared_wallet_view_permissions(from_wallet: Wallet):
await pay_invoice(wallet_id=from_wallet.id, payment_request=payment.bolt11)
wallet_balance += payment.sat
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
filters = Filters(limit=100, model=PaymentFilters)
shared_wallet_payments = await get_payments(
wallet_id=mirror_wallet.id, filters=filters
)
assert len(shared_wallet_payments) == payment_count
mirror_wallet = await get_wallet(mirror_wallet.id)
assert mirror_wallet is not None