Compare commits

..
90 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
Vlad StanandGitHub 44985cb0d1 [perf] Performance bust paginated search (#3543) 2025-11-21 18:29:23 +02:00
dni ⚡andGitHub b1a7692ce4 refactor: move to lnbits-wallet-icon vue component (#3535) 2025-11-21 11:53:20 +01:00
dni ⚡andGitHub 31bff37b1e fix: pagination vanished on chrome (#3549) 2025-11-21 11:36:18 +01:00
dni ⚡andGitHub 3f7a1798d5 refactor: create lnbits-wallet-paylinks component (#3523) 2025-11-21 11:05:38 +01:00
dni ⚡andGitHub 98d4dbab8b fix: payment labels route dynamically to account#labels (#3548) 2025-11-21 10:58:44 +02:00
Vlad StanandGitHub 7c7a04da9d [feat] Payment labels (#3537) 2025-11-21 10:33:53 +02:00
dni ⚡andGitHub 3ccefb70fa chore: getting rid of jinja variable in wallet.html (#3547) 2025-11-21 09:01:53 +01:00
dni ⚡andGitHub 5346ac47b1 feat: move disclaimer from wallet.html into vue component (#3536) 2025-11-21 08:39:17 +01:00
dni ⚡andGitHub 9f4ed53888 chore: remove unused full withdraw (#3544) 2025-11-21 08:38:09 +01:00
dni ⚡andGitHub faac56c14c refactor: lnbits-wallet-charts and clean up paymentFiltering (#3526) 2025-11-20 15:01:37 +01:00
dni ⚡andGitHub 3e0d0d9896 fix: theme global initialisation when not defined was wrong (#3542) 2025-11-20 11:59:00 +01:00
b9a004a5e4 fix: login screen layout (#3538)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-20 10:44:56 +02:00
Tiago VasconcelosandGitHub da3cb548bd fix: clarify payment wait time (#3540) 2025-11-19 15:50:51 +02:00
dni ⚡andGitHub 4144359617 refactor: lnbits-wallet-ads, ads on wallet page (#3534) 2025-11-17 09:14:34 +01:00
Tiago VasconcelosandGitHub 07268b87d2 fix: allow str instead of number (#3531) 2025-11-17 09:29:16 +02:00
dni ⚡andGitHub c356874bcb fix: check for null string on localstorage (#3529) 2025-11-14 14:15:04 +01:00
dni ⚡andGitHub f9b5c7db7a refactor: rename and fix lnbits-wallet-new (#3528) 2025-11-14 14:08:40 +01:00
dni ⚡andGitHub 20f9ddb57c fix: chart color for darkmode (#3525) 2025-11-13 17:12:35 +01:00
dni ⚡andGitHub ee57ba4c1a fix: regression in node.js (#3521) 2025-11-13 16:07:09 +01:00
dni ⚡andGitHub ff7f5c1ca5 refactor: move _wallet-share.html to vue component (#3522) 2025-11-13 15:48:46 +01:00
dni ⚡andGitHub 1463d75ee2 refactor: move _api_docs.html into vue component (#3520) 2025-11-13 15:37:59 +01:00
dni ⚡andGitHub f1fc4710ee refactor: move walletTypes from mixin into lnbits-new-user-wallet (#3519) 2025-11-13 13:29:43 +01:00
dni ⚡andGitHub 00eaec8290 refactor: use mobileSimple reactive value (#3518) 2025-11-13 13:17:27 +01:00
dni ⚡andGitHub 28c4235c94 refactor: rename payment-list to lnbits-payment-list (#3517) 2025-11-13 12:20:51 +01:00
dni ⚡andGitHub b7d178c08e feat: refactor create lnbits-theme vue component (#3515) 2025-11-13 11:50:33 +01:00
dni ⚡andGitHub 9d0ec97d39 refactor: create last base.html vue component (#3514) 2025-11-12 15:16:13 +01:00
dni ⚡andGitHub b6f533be24 feat: add language to topmenu + globals cleanup + i18n refactor (#3510) 2025-11-12 14:07:53 +00:00
dni ⚡andGitHub 5e36bb4fcd fix: bug in renaming lnbits-extension-list (#3513) 2025-11-12 14:13:04 +01:00
dni ⚡andGitHub f9b0604711 fix: extension enable reactivity (#3512) 2025-11-12 13:41:01 +01:00
Vlad StanandGitHub 39c33699af [feat] user assets (#3504) 2025-11-12 14:30:27 +02:00
c89721223f feat: add configurable threshold for balance delta notification (#3433)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-12 11:36:19 +02:00
dni ⚡andGitHub 1e6e97c12d refactor: move logout to utils (#3509) 2025-11-12 10:14:27 +01:00
dni ⚡andGitHub 783efb19db fix: wallet was not flipping (#3508) 2025-11-12 09:54:54 +01:00
Vlad StanandGitHub 9ffc63b5dc fix: pay_invoice http error (#3506) 2025-11-12 09:22:49 +02:00
Tiago VasconcelosandGitHub 0ec8139e5c fix: vertically center first install (#3501) 2025-11-11 21:04:43 +01:00
dni ⚡andGitHub a8c3181852 fix: check_callback_url in dispatch_webhook was not handled (#3503) 2025-11-11 12:46:59 +02:00
e038ceb9be refactor: lndrest get_payment_status and pay_invoice use api v2 (#3470)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-10 16:26:14 +01:00
DoktorShiftandGitHub b4eccb9e5d Revise LNBits Admin UI documentation (#3443) 2025-11-10 16:08:33 +02:00
dni ⚡andGitHub 691b7ad055 refactor: lndgrpc, update grpcs file, use types and enums, LookupInvoiceV2 (#3469) 2025-11-10 15:07:00 +01:00
c84d1b66c6 fix: update API response structure and fix checking_id mismatch (#3478)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-10 16:05:50 +02:00
dni ⚡andGitHub cf7726ddfd refactor into '<lnbits-drawer />' component (#3499) 2025-11-10 14:54:06 +01:00
dni ⚡andGitHub bd50a3c546 refactor move home / into vue components (#3498) 2025-11-10 14:48:27 +01:00
dni ⚡andGitHub c9270bbf7f refactor into <lnbits-header /> component (#3488) 2025-11-10 13:56:32 +01:00
dni ⚡andGitHub 6474aeb982 refactor into <lnbits-footer /> component (#3487) 2025-11-10 11:53:20 +01:00
dni ⚡andGitHub 37ba437ad1 fix: incorrect styles in base.html from #3431 (#3486) 2025-11-10 11:48:09 +01:00
Vlad StanandGitHub 35af181e24 feat: migrate changes from PR #3456 (#3496) 2025-11-10 12:21:30 +02:00
dni ⚡andGitHub f8c58aef0e feat: move /first_install to vue component (#3485) 2025-11-10 11:17:29 +01:00
Vlad StanandGitHub 8755984bd8 [test] admin paths (#3494) 2025-11-10 12:00:55 +02:00
dni ⚡andGitHub 0d5661cda7 feat: move '/extensions' into vue component (#3480) 2025-11-10 10:20:18 +01:00
dni ⚡andGitHub c2a3fbc6c0 feat: move extension/builder into vue component (#3479) 2025-11-10 10:05:08 +01:00
dni ⚡andGitHub 6bac34fc6b refactor: use decorators for disabled endpoints (#3481) 2025-11-10 09:33:16 +01:00
Ben WeeksandGitHub b54eedee84 feat: Shared Wallets/Joint Accounts (Issue #3297) (#3376) 2025-11-07 22:25:03 +02:00
dni ⚡andGitHub bd07f7a5ef refactor: move /account into vue component (#3467) 2025-11-07 18:17:15 +01:00
dni ⚡andGitHub a40306f5cd refactor: move /admin into vue component (#3466) 2025-11-07 18:10:59 +01:00
dni ⚡andGitHub 2fecec2623 refactor: move /users into vue component (#3463) 2025-11-06 10:35:29 +01:00
dni ⚡andGitHub 4ad89396cc refactor: move /wallets into vue component (#3462) 2025-11-06 09:55:25 +01:00
dni ⚡andGitHub 82307c4839 refactor: move /audit into vue component (#3461) 2025-11-06 09:50:01 +01:00
dni ⚡andGitHub 8506199c2f refactor: move /node into vue component (#3460) 2025-11-06 09:45:41 +01:00
dni ⚡andGitHub d142d76148 refactor: simplify base.js (#3473) 2025-11-04 08:40:25 +01:00
dni ⚡andGitHub 98e9a61b98 fix: markdown rendering on index.html (#3472) 2025-11-04 08:40:10 +01:00
dni ⚡andGitHub 877af0c21e fix: broken html /docs use gfm (#3471) 2025-11-03 11:34:24 +01:00
dni ⚡andGitHub dd27b190f3 refactor: move /payments into vue component (#3414) 2025-10-31 08:15:01 +01:00
dni ⚡andGitHub 2856803ca7 fix: handle all lnurl exceptions on lnurlscan endpoint (#3451) 2025-10-30 08:07:25 +01:00
dni ⚡andGitHub ca8264b1f5 feat: remove nfc not supported (#3453) 2025-10-30 08:07:10 +01:00
dni ⚡andGitHub 39ca9da870 feat: NWC use coincurve instead of secp (#3455) 2025-10-30 08:06:31 +01:00
dni ⚡andGitHub 2b603bdc48 fix: add dependency-groups get rid of warning (#3454) 2025-10-29 15:59:31 +01:00
26780df065 nix: Re-enable and fix basic nix flake check for nix package and module (#3425)
Co-authored-by: Weston Keele <wekeele@proton.me>
2025-10-29 15:58:43 +01:00
dni ⚡andGitHub 248fcc06ab fix: release ci, pass upload_url from task to task (#3449) 2025-10-27 15:17:53 +02:00
Ben WeeksandGitHub f794373d30 feat: improve VoidWallet warning visibility on all devices (#3428) 2025-10-27 10:12:37 +02:00
Vlad StanandGitHub 87f25ea715 fix: LND GRPC macaroon fields (#3444) 2025-10-24 13:49:33 +03:00
Vlad StanandGitHub b9de754598 fix: exclude (soft) deleted wallets when creating an invoice (#3439) 2025-10-21 14:59:04 +03:00
785fb7af8e fix: improve mobile responsiveness for admin settings (#3431)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-21 13:54:49 +03:00
dni ⚡andGitHub 55c5ab3a6d feat: ci appimage have manual triggers (#3424) 2025-10-17 13:11:44 +01:00
211 changed files with 22581 additions and 14290 deletions
+21 -2
View File
@@ -2,7 +2,26 @@ name: Build LNbits AppImage
on:
workflow_call:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -75,7 +94,7 @@ jobs:
# Build AppImage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ github.event.release.tag_name }}
TAG_NAME=${{ inputs.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage \
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
@@ -93,7 +112,7 @@ jobs:
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ github.event.release.upload_url }}
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
+7 -1
View File
@@ -13,6 +13,8 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -20,7 +22,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
needs: [ release ]
@@ -56,3 +59,6 @@ jobs:
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+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
+1
View File
@@ -4,6 +4,7 @@ color_scheme: dark
logo: "/logos/lnbits-full-inverse.png"
search_enabled: true
url: https://docs.lnbits.org
markdown: gfm
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"
@@ -1,3 +1,9 @@
---
layout: default
title: FastAPI extension upgrade
nav_order: 1
---
## Defining a route with path parameters
**old:**
+34 -22
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Admin UI
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -12,11 +18,13 @@
# LNBits Admin UI
We introduced the Admin UI as the new default to make setup simpler and more straightforward. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides)
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
@@ -36,6 +44,27 @@ For a complete reference of legacy variables consult **[.env.example](../../.env
> [!NOTE]
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
## First run and Super User ID
On first start with the Admin UI enabled you will be prompted to generate a Super User.
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
If you need to read it from disk later:
```bash
cat /lnbits/data/.super_user
# example
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
## Enabling or disabling the Admin UI
The Admin UI is enabled by default on new installs. To change the state:
@@ -76,23 +105,6 @@ The Admin UI is enabled by default on new installs. To change the state:
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
## First run and Super User ID
On first start with the Admin UI enabled you will be prompted to generate a Super User. If you need to read it from disk later:
```bash
cat /lnbits/data/.super_user
# example
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
## Allowed Users
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
@@ -105,8 +117,8 @@ When set **at least one**, LNbits becomes private: only the listed users and Adm
## Additional Guides
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
- **[User Roles](./User_Roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources_table.md)** — What is available and how to configure each.
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
@@ -115,7 +127,7 @@ LNbits empowers everyone with modular, open source tools for building Bitcoin ba
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/)
[![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/)
+6
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Extension Install
nav_order: 1
---
# Extension Install
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
+7 -1
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Wallet comparison
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -72,7 +78,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
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/)
[![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/)
+33 -27
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Installation
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -51,8 +57,8 @@ LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most syst
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2: UV (recommended for developers)
@@ -117,8 +123,8 @@ uv run lnbits
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2a (Legacy): Poetry — _Replaced by UV_
@@ -197,8 +203,8 @@ poetry run lnbits
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -217,8 +223,8 @@ chmod +x lnbits.sh &&
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -286,7 +292,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> **Next steps**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -340,8 +346,8 @@ docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -380,14 +386,14 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949)
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
```
@@ -445,8 +451,8 @@ poetry add setuptools wheel
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b)
>
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Troubleshooting
@@ -524,22 +530,22 @@ Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
> [!NOTE]
> **Paths overview**
>
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> The container reads `/app/.env` (usually bind-mounted from your project root).
> [!TIP]
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> https://lightningpolar.com/
> [!TIP]
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> https://arbadacarbayk.github.io/LNbits_TableTown/
# Additional guides
@@ -884,5 +890,5 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![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/)
+8 -2
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Super user
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -110,8 +116,8 @@ These are practical tips for running a safe and friendly instance.
## Additional guides
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
- **[User Roles](./User_Roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources_table.md)** — Available options and how to enable and configure them.
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
+9 -3
View File
@@ -1,3 +1,9 @@
---
layout: default
title: User Roles
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -14,7 +20,7 @@
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
**Jump to:**
**Jump to:**
[Roles at a Glance](#roles-at-a-glance) •
[Super User](#super-user--master-control) •
[Admin](#admin--day-to-day-manager) •
@@ -81,7 +87,7 @@ For using LNbits, not administering it.
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
@@ -89,7 +95,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
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/)
[![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/)
+2 -2
View File
@@ -354,8 +354,8 @@ Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.s
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
+2 -1
View File
@@ -145,6 +145,7 @@
nixpkgs.overlays = [ self.overlays.${system}.default ];
};
checks = { };
checks =
import ./nix/tests { inherit pkgs; flake = self; };
});
}
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
@@ -44,6 +45,7 @@ def init_core_routers(app: FastAPI):
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
+108
View File
@@ -0,0 +1,108 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * FROM assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
table_name="assets",
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)
+2 -1
View File
@@ -16,11 +16,12 @@ async def get_audit_entries(
conn: Connection | None = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
"SELECT * FROM audit",
[],
{},
filters=filters,
model=AuditEntry,
table_name="audit",
)
+20 -8
View File
@@ -39,7 +39,6 @@ async def get_standalone_payment(
) -> Payment | None:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash,
"hash": checking_id_or_hash,
}
@@ -47,6 +46,10 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0"
if wallet_id:
wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments:
return None
values["wallet_id"] = wallet.source_wallet_id
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
@@ -66,13 +69,16 @@ async def get_standalone_payment(
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None:
wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments:
return None
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet_id, "hash": payment_hash},
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
Payment,
)
return payment
@@ -128,7 +134,11 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
values["wallet_id"] = wallet_id
wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
values["wallet_id"] = wallet.source_wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
@@ -171,6 +181,7 @@ async def get_payments_paginated( # noqa: C901
values,
filters=filters,
model=Payment,
table_name="apipayments",
)
@@ -282,6 +293,7 @@ async def create_payment(
fee=-abs(data.fee),
tag=extra.get("tag", None),
extra=extra,
labels=data.labels or [],
)
await (conn or db).insert("apipayments", payment)
@@ -320,7 +332,7 @@ async def get_payments_history(
date_trunc = db.datetime_grouping(group)
values = {
values: dict[str, Any] = {
"wallet_id": wallet_id,
}
# count outgoing payments if they are still pending
@@ -350,10 +362,10 @@ async def get_payments_history(
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
if not wallet or not wallet.can_view_payments:
return []
balance = wallet.balance_msat
values["wallet_id"] = wallet.source_wallet_id
else:
balance = await get_total_balance()
+9 -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
@@ -89,6 +89,7 @@ async def get_accounts(
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
table_name="accounts",
)
@@ -179,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,
+81 -13
View File
@@ -3,7 +3,7 @@ from time import time
from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import WalletsFilters
from lnbits.core.models.wallets import WalletsFilters, WalletType
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings
@@ -14,17 +14,22 @@ async def create_wallet(
*,
user_id: str,
wallet_name: str | None = None,
wallet_type: WalletType = WalletType.LIGHTNING,
shared_wallet_id: str | None = None,
conn: Connection | None = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
wallet_type=wallet_type.value,
shared_wallet_id=shared_wallet_id,
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
)
await (conn or db).insert("wallets", wallet)
return wallet
@@ -103,8 +108,8 @@ async def delete_unused_wallets(
)
async def get_wallet(
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
async def get_standalone_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
query = """
SELECT *, COALESCE((
@@ -121,8 +126,23 @@ async def get_wallet(
)
async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
return await get_source_wallet(wallet, conn)
return wallet
async def get_wallets(
user_id: str, deleted: bool | None = None, conn: Connection | None = None
user_id: str,
deleted: bool | None = False,
wallet_type: WalletType | None = None,
conn: Connection | None = None,
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
@@ -132,12 +152,20 @@ async def get_wallets(
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchall(
if wallet_type is not None:
query += " AND wallet_type = :wallet_type "
wallets = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
{
"user": user_id,
"deleted": deleted,
"wallet_type": wallet_type.value if wallet_type else None,
},
Wallet,
)
return await get_source_wallets(wallets, conn)
async def get_wallets_paginated(
user_id: str,
@@ -149,7 +177,7 @@ async def get_wallets_paginated(
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
return await (conn or db).fetch_page(
wallets = await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -159,20 +187,27 @@ async def get_wallets_paginated(
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
table_name="wallets",
)
wallets.data = await get_source_wallets(wallets.data, conn)
return wallets
async def get_wallets_ids(
user_id: str, deleted: bool | None = None, conn: Connection | None = None
user_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> list[str]:
query = """SELECT id FROM wallets WHERE "user" = :user"""
query = """SELECT * FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += "AND deleted = :deleted"
result: list[dict] = await (conn or db).fetchall(
query += " AND deleted = :deleted "
wallets = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
Wallet,
)
return [row["id"] for row in result]
wallets = await get_source_wallets(wallets, conn)
return [w.source_wallet_id for w in wallets if w.can_view_payments]
async def get_wallets_count():
@@ -185,7 +220,7 @@ async def get_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> Wallet | None:
return await (conn or db).fetchone(
wallet = await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -196,6 +231,39 @@ async def get_wallet_for_key(
{"key": key},
Wallet,
)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
mw = await get_source_wallet(wallet, conn)
return mw
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
if not wallet.is_lightning_shared_wallet:
return wallet
if not wallet.shared_wallet_id:
return None
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
if not shared_wallet:
return None
wallet.mirror_shared_wallet(shared_wallet)
return wallet
async def get_source_wallets(
wallet: list[Wallet], conn: Connection | None = None
) -> list[Wallet]:
source_wallets = []
for w in wallet:
source_wallet = await get_source_wallet(w, conn)
if source_wallet:
source_wallets.append(source_wallet)
return source_wallets
async def get_total_balance(conn: Connection | None = None):
+43
View File
@@ -743,3 +743,46 @@ async def m034_add_stored_paylinks_to_wallet(db: Connection):
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)
async def m035_add_wallet_type_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
"""
)
async def m036_add_shared_wallet_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
"""
)
async def m037_create_assets_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None
+17 -1
View File
@@ -59,6 +59,7 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel):
@@ -81,6 +82,7 @@ class Payment(BaseModel):
time: 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))
labels: list[str] = []
extra: dict = {}
def __init__(self, **data):
@@ -184,7 +186,15 @@ class Payment(BaseModel):
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"]
@@ -198,6 +208,7 @@ class PaymentFilters(FilterModel):
preimage: str | None
payment_hash: str | None
wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel):
@@ -272,6 +283,7 @@ class CreateInvoice(BaseModel):
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash")
def check_hex(cls, v):
@@ -320,3 +332,7 @@ class CancelInvoice(BaseModel):
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
+75 -7
View File
@@ -12,6 +12,7 @@ from lnbits.db import FilterModel
from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_label,
is_valid_pubkey,
is_valid_username,
)
@@ -29,6 +30,21 @@ class UserNotifications(BaseModel):
incoming_payments_sats: int = 0
class WalletInviteRequest(BaseModel):
request_id: str
from_user_name: str | None = None
to_wallet_id: 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):
email_verified: bool | None = False
first_name: str | None = None
@@ -46,6 +62,55 @@ class UserExtra(BaseModel):
notifications: UserNotifications = UserNotifications()
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
to_wallet_id: str,
to_wallet_name: str,
from_user_name: str | None = None,
) -> WalletInviteRequest:
self.remove_wallet_invite_request(request_id)
invite = WalletInviteRequest(
request_id=request_id,
from_user_name=from_user_name,
to_wallet_id=to_wallet_id,
to_wallet_name=to_wallet_name,
)
self.wallet_invite_requests.append(invite)
return invite
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
for invite in self.wallet_invite_requests:
if invite.request_id == request_id:
return invite
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(
self,
request_id: str,
):
self.wallet_invite_requests = [
invite
for invite in self.wallet_invite_requests
if invite.request_id != request_id
]
class EndpointAccess(BaseModel):
path: str
@@ -160,6 +225,8 @@ class Account(BaseModel):
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account):
transaction_count: int | None = 0
@@ -170,7 +237,7 @@ class AccountOverview(Account):
class AccountFilters(FilterModel):
__search_fields__ = [
"user",
"id",
"email",
"username",
"pubkey",
@@ -178,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
+160 -8
View File
@@ -4,12 +4,10 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.settings import settings
@@ -21,10 +19,95 @@ class BaseWallet(BaseModel):
balance_msat: int
class WalletType(Enum):
LIGHTNING = "lightning"
LIGHTNING_SHARED = "lightning-shared"
class WalletPermission(Enum):
VIEW_PAYMENTS = "view-payments"
RECEIVE_PAYMENTS = "receive-payments"
SEND_PAYMENTS = "send-payments"
def __str__(self):
return self.value
class WalletShareStatus(Enum):
INVITE_SENT = "invite_sent"
APPROVED = "approved"
class WalletSharePermission(BaseModel):
# unique identifier for this share request
request_id: str | None = None
# username of the invited user
username: str
# ID of the wallet being shared with
shared_with_wallet_id: str | None = None
# permissions being granted
permissions: list[WalletPermission] = []
# status of the share request
status: WalletShareStatus
comment: str | None = None
def approve(
self,
permissions: list[WalletPermission] | None = None,
shared_with_wallet_id: str | None = None,
):
self.status = WalletShareStatus.APPROVED
if permissions is not None:
self.permissions = permissions
if shared_with_wallet_id is not None:
self.shared_with_wallet_id = shared_with_wallet_id
@property
def is_approved(self) -> bool:
return self.status == WalletShareStatus.APPROVED
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
# What permissions this wallet grants when it's shared with other users
shared_with: list[WalletSharePermission] = []
def invite_user_to_shared_wallet(
self,
request_id: str,
request_type: WalletShareStatus,
username: str,
permissions: list[WalletPermission] | None = None,
) -> WalletSharePermission:
share = WalletSharePermission(
request_id=request_id,
username=username,
status=request_type,
permissions=permissions or [],
)
self.shared_with.append(share)
return share
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
for share in self.shared_with:
if share.request_id == request_id:
return share
return None
def find_share_for_wallet(
self, shared_with_wallet_id: str
) -> WalletSharePermission | None:
for share in self.shared_with:
if share.shared_with_wallet_id == shared_with_wallet_id:
return share
return None
def remove_share_by_id(self, request_id: str):
self.shared_with = [
share for share in self.shared_with if share.request_id != request_id
]
class Wallet(BaseModel):
@@ -33,6 +116,9 @@ class Wallet(BaseModel):
name: str
adminkey: str
inkey: str
wallet_type: str = WalletType.LIGHTNING.value
# Must be set only for shared wallets
shared_wallet_id: str | None = None
deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -40,6 +126,65 @@ class Wallet(BaseModel):
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self._validate_data()
def mirror_shared_wallet(
self,
shared_wallet: Wallet,
):
if not shared_wallet.is_lightning_wallet:
return None
self.wallet_type = WalletType.LIGHTNING_SHARED.value
self.shared_wallet_id = shared_wallet.id
self.name = shared_wallet.name
self.share_permissions = shared_wallet.get_share_permissions(self.id)
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
self.extra.icon = shared_wallet.extra.icon
self.extra.color = shared_wallet.extra.color
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
for share in self.extra.shared_with:
if share.shared_with_wallet_id == wallet_id and share.is_approved:
return share.permissions
return []
def has_permission(self, permission: WalletPermission) -> bool:
if self.is_lightning_wallet:
return True
if self.is_lightning_shared_wallet:
return permission in self.share_permissions
return False
@property
def source_wallet_id(self) -> str:
"""For shared wallets return the original wallet ID, else return own ID."""
if self.is_lightning_shared_wallet and len(self.share_permissions):
return self.shared_wallet_id or self.id
return self.id
@property
def can_receive_payments(self) -> bool:
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
@property
def can_send_payments(self) -> bool:
return self.has_permission(WalletPermission.SEND_PAYMENTS)
@property
def can_view_payments(self) -> bool:
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
@property
def balance(self) -> int:
@@ -50,16 +195,23 @@ class Wallet(BaseModel):
return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def is_lightning_wallet(self) -> bool:
return self.wallet_type == WalletType.LIGHTNING.value
@property
def is_lightning_shared_wallet(self) -> bool:
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
def _validate_data(self):
if self.is_lightning_shared_wallet:
if not self.shared_wallet_id:
raise ValueError("Shared wallet ID must be set for shared wallets.")
class CreateWallet(BaseModel):
name: str | None = None
wallet_type: WalletType = WalletType.LIGHTNING
shared_wallet_id: str | None = None
class KeyType(Enum):
+56
View File
@@ -0,0 +1,56 @@
import base64
import io
from uuid import uuid4
from fastapi import UploadFile
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if user_id not in settings.lnbits_assets_no_limit_users:
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
)
contents = await file.read()
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
raise ValueError(
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=file.content_type,
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
data=contents,
)
await create_asset(asset)
return asset
+2 -1
View File
@@ -70,7 +70,8 @@ async def check_balance_delta_changed():
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
return
if status.delta_sats != settings.latest_balance_delta_sats:
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats)
if delta_change >= settings.notification_balance_delta_threshold_sats:
enqueue_admin_notification(
NotificationType.balance_delta,
{
+12 -1
View File
@@ -16,6 +16,7 @@ from lnbits.core.crud import (
mark_webhook_sent,
)
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
@@ -240,6 +241,10 @@ async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(payment.webhook)
except ValueError as exc:
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
try:
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
@@ -257,6 +262,12 @@ async def dispatch_webhook(payment: Payment):
async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
for shared in wallet.extra.shared_with:
if not shared.shared_with_wallet_id:
continue
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
if shared_wallet and shared_wallet.can_view_payments:
await send_ws_payment_notification(shared_wallet, payment)
except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}")
try:
@@ -381,7 +392,7 @@ def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
return settings.notification_balance_delta_threshold_sats > 0
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
+22 -6
View File
@@ -61,6 +61,7 @@ async def pay_invoice(
extra: dict | None = None,
description: str = "",
tag: str = "",
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
@@ -72,6 +73,11 @@ async def pay_invoice(
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
if not wallet.can_send_payments:
raise PaymentError(
"Wallet does not have permission to pay invoices.",
status="failed",
)
if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed")
@@ -79,16 +85,17 @@ async def pay_invoice(
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
wallet_id=wallet.source_wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
labels=labels,
)
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(wallet, payment, new_conn)
@@ -202,6 +209,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
labels=data.labels,
)
if data.lnurl_withdraw:
@@ -241,6 +249,7 @@ async def create_invoice(
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@@ -250,6 +259,12 @@ async def create_invoice(
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
if not user_wallet.can_receive_payments:
raise InvoiceError(
"Wallet does not have permission to create invoices.",
status="failed",
)
invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only
@@ -308,7 +323,7 @@ async def create_invoice(
invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
wallet_id=user_wallet.source_wallet_id,
bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash,
preimage=invoice_response.preimage,
@@ -318,6 +333,7 @@ async def create_invoice(
extra=extra,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
)
payment = await create_payment(
@@ -456,7 +472,7 @@ async def update_wallet_balance(
await create_payment(
checking_id=f"internal_{payment_hash}",
data=CreatePayment(
wallet_id=wallet.id,
wallet_id=wallet.source_wallet_id,
bolt11=bolt11,
payment_hash=payment_hash,
amount_msat=amount * 1000,
@@ -475,7 +491,7 @@ async def update_wallet_balance(
raise ValueError("Balance change failed, amount exceeds maximum balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment = await create_invoice(
wallet_id=wallet.id,
wallet_id=wallet.source_wallet_id,
amount=amount,
memo="Admin credit",
internal=True,
@@ -910,7 +926,7 @@ async def _credit_service_fee_wallet(
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
+202
View File
@@ -0,0 +1,202 @@
from lnbits.core.crud.users import (
get_account,
get_account_by_username_or_email,
update_account,
)
from lnbits.core.crud.wallets import (
create_wallet,
force_delete_wallet,
get_standalone_wallet,
get_wallet,
get_wallets,
update_wallet,
)
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account
from lnbits.core.models.wallets import (
Wallet,
WalletSharePermission,
WalletShareStatus,
WalletType,
)
from lnbits.db import Connection
from lnbits.helpers import sha256s
async def invite_to_wallet(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.username:
raise ValueError("Username or email missing.")
invited_user = await get_account_by_username_or_email(data.username)
if not invited_user:
raise ValueError("Invited user not found.")
request_id = sha256s(invited_user.id + source_wallet.id)
share = source_wallet.extra.find_share_by_id(request_id)
if share:
raise ValueError("User already invited to this wallet.")
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
request_id=request_id,
request_type=WalletShareStatus.INVITE_SENT,
username=data.username,
permissions=data.permissions,
)
await update_wallet(source_wallet)
wallet_owner = await get_account(source_wallet.user)
if not wallet_owner:
raise ValueError("Cannot find wallet owner.")
invited_user.extra.add_wallet_invite_request(
request_id=request_id,
from_user_name=wallet_owner.username or wallet_owner.email,
to_wallet_id=source_wallet.id,
to_wallet_name=source_wallet.name,
)
await update_account(invited_user)
return invite_request
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
invited_user = await get_account(invited_user_id)
if not invited_user:
raise ValueError("Invited user not found.")
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
if not existing_request:
raise ValueError("Invitation not found.")
invited_user.extra.remove_wallet_invite_request(share_request_id)
await update_account(invited_user)
async def update_wallet_share_permissions(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.shared_with_wallet_id:
raise ValueError("Wallet ID missing.")
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
if not share:
raise ValueError("Share not found")
if not share.shared_with_wallet_id:
raise ValueError("Share does not have a mirror wallet ID.")
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
if not mirror_wallet:
raise ValueError("Target wallet not found")
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
share.approve(permissions=data.permissions)
await update_wallet(source_wallet)
return share
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
if not source_wallet.is_lightning_wallet:
raise ValueError("Source wallet is not a lightning wallet.")
share = source_wallet.extra.find_share_by_id(request_id)
if not share:
raise ValueError("Wallet share not found.")
source_wallet.extra.remove_share_by_id(request_id)
invited_user = await get_account_by_username_or_email(share.username)
if not invited_user:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Invited user not found."
)
if invited_user.extra.find_wallet_invite_request(request_id):
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
mirror_wallets = await get_wallets(
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
)
mirror_wallet = next(
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
)
if not mirror_wallet:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Target wallet not found."
)
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared lightning wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
await force_delete_wallet(mirror_wallet.id)
await update_wallet(source_wallet)
return SimpleStatus(success=True, message="Permission removed.")
async def create_lightning_shared_wallet(
user_id: str,
source_wallet_id: str,
conn: Connection | None = None,
) -> Wallet:
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
if not source_wallet:
raise ValueError("Shared wallet does not exist.")
if not source_wallet.is_lightning_wallet:
raise ValueError("Shared wallet is not a lightning wallet.")
if source_wallet.user == user_id:
raise ValueError("Cannot mirror your own wallet.")
invited_user = await get_account(user_id, conn=conn)
if not invited_user:
raise ValueError("Cannot find invited user.")
return await _accept_invitation_to_shared_wallet(
invited_user, source_wallet, conn=conn
)
async def _accept_invitation_to_shared_wallet(
invited_user: Account,
source_wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
request_id = sha256s(invited_user.id + source_wallet.id)
existing_request = source_wallet.extra.find_share_by_id(request_id)
if not existing_request:
raise ValueError("No invitation found for this invited user.")
if existing_request.status == WalletShareStatus.APPROVED:
raise ValueError("This wallet is already shared with you.")
if existing_request.status != WalletShareStatus.INVITE_SENT:
raise ValueError("Unknown request type.")
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
# todo: double check if user already has a mirror wallet for this source wallet
mirror_wallet = await create_wallet(
user_id=invited_user.id,
wallet_name=source_wallet.name,
wallet_type=WalletType.LIGHTNING_SHARED,
shared_wallet_id=source_wallet.id,
conn=conn,
)
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
await update_wallet(source_wallet, conn=conn)
mirror_wallet.mirror_shared_wallet(source_wallet)
return mirror_wallet
+1 -1
View File
@@ -35,7 +35,7 @@ async def run_by_the_minute_tasks() -> None:
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_changed:
if settings.notification_balance_delta_threshold_sats > 0:
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
@@ -1,197 +0,0 @@
<q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row">
<div class="col-md-8 col-sm-12">
<div class="q-pa-sm">
<canvas
style="
width: 100% !important;
height: auto !important;
min-height: 350px;
max-height: 50vh;
"
ref="exchangeRatesChart"
></canvas>
</div>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number"
label="Refresh Interval (seconds)"
hint="How often should the exchange rates be fetched. Set to zero to disable."
>
</q-input>
<q-input
filled
v-model="formData.lnbits_exchange_history_size"
type="number"
label="History Size"
hint="How many data points should be kept in memory."
>
</q-input>
<ul>
<li>
<code>Refresh Interval</code> and <code> History Size </code>are for
historical purposes only.
</li>
<li>These two settings do not affect the live price computation.</li>
<li>
Chart currency:
<strong
><span
v-text="formData.lnbits_default_accounting_currency || 'USD'"
></span
></strong>
</li>
</ul>
</div>
</div>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@click="addExchangeProvider()"
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
>
</q-btn>
</div>
<div class="col-6">
<q-btn
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
flat
:label="$t('reset_defaults')"
color="primary"
class="float-right"
>
</q-btn>
</div>
</div>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
:columns="exchangesTable.columns"
v-model:pagination="exchangesTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
@click="removeExchangeProvider(props.row)"
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
>
</q-btn>
</q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.name"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td full-width>
<q-input
dense
filled
v-model="props.row.api_url"
@update:model-value="touchSettings()"
type="text"
>
</q-input
></q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.path"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td>
<q-select
filled
dense
v-model="props.row.exclude_to"
@update:model-value="touchSettings()"
multiple
:options="{{ currencies | safe }}"
></q-select>
</q-td>
<q-td>
<q-btn
@click="showTickerConversionDialog(props.row)"
round
icon="add"
size="sm"
color="gray"
class="q-ml-xs"
>
</q-btn>
<q-chip
v-for="ticker, index in props.row.ticker_conversion"
:key="ticker"
removable
dense
filled
@remove="removeExchangeTickerConversion(props.row, ticker)"
color="primary"
text-color="white"
:label="ticker"
class="ellipsis"
>
</q-chip>
</q-td>
</q-tr>
</template>
</q-table>
<ul>
<li>
<code>API URL</code> and <code>JSON Path</code> fields can use the
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
currency
</li>
<li>
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
lowercase code
</li>
</ul>
<q-separator class="q-ma-md"></q-separator>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_rate_cache_seconds"
type="number"
label="Exchange rate cache (seconds)"
hint="For how many seconds should the exchange rate be cached."
>
</q-input>
</div>
<div class="col-md-8 col-sm-12"></div>
</div>
</q-tab-panel>
@@ -1,67 +0,0 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
-251
View File
@@ -1,251 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
<div></div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<q-splitter>
<template v-slot:before>
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="funding"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="exchange_providers"
icon="show_chart"
:label="$q.screen.gt.sm ? $t('exchanges') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span></q-tooltip
></q-tab>
<q-tab
name="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</template>
</q-splitter>
</q-card>
</div>
</div>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="q-mb-md">
<strong v-text="$t('create_ticker_converter')"></strong>
</div>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
-201
View File
@@ -1,201 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<!-- Optional: Add content here if needed -->
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#audit"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center q-mb-lg">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('components')"></strong>
<div style="width: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('long_running_endpoints')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_request_methods')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_response_codes')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip
><span v-text="$t('request_details')"></span
></q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('http_request_details')"></strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
-305
View File
@@ -1,305 +0,0 @@
<q-expansion-item
group="extras"
icon="vpn_key"
:label="$t('api_keys_api_docs')"
:content-inset-level="0.5"
>
<q-card-section>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
"expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "internal": &lt;bool&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
<p v-text="$t('reset_wallet_keys_desc')"></p>
<q-btn
unelevated
color="red-10"
@click="resetKeys()"
:label="$t('reset_wallet_keys')"
></q-btn>
</q-card-section>
</q-expansion-item>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,830 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row">
<div class="col-12">
<q-stepper
v-model="step"
ref="stepper"
color="primary"
animated
header-nav
class="q-pt-sm"
@update:model-value="onStepChange"
>
<q-step
:name="1"
title="Describe"
icon="info"
:done="step > 1"
style="min-height: 100px"
>
<div class="row q-col-gutter-md">
<div class="col-12">
<span class="text-h6">
Tell us something about your extension:
</span>
<ul>
<li>This is the first step, you can return and change it.</li>
<li>
The <code>`name`</code> and
<code>`sort description`</code> fields are what the users will
see when browsing the list of extensions.
</li>
<li>
The <code>`id`</code> field is used internally and in the URL of
your extension.
</li>
</ul>
</div>
<!-- todo: add icon -->
<div class="col-12">
<div>
<q-btn
color="primary"
label="Upload Existing config"
@click="$refs.extensionDataInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="extensionDataInput"
accept="application/json"
style="display: none"
@change="onJsonDataInput"
/>
</div>
</div>
</div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.name"
label="Extension Name"
hint="The name of your extension"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.id"
label="Extension Id"
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.short_description"
label="Short Description"
hint="A short description that is shown in the extension list."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
v-model="extensionData.description"
label="Description"
hint="A detailed description of your extension."
type="textarea"
rows="3"
maxlength="1000"
>
</q-input>
</div>
</div>
</q-step>
<q-step
:name="2"
title="Settings"
icon="settings"
:done="step > 2"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep2"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('settings')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.settings_data.enabled"
label="Generate Settings Fields"
size="md"
color="green"
/>
<br />
<ul>
<li>Define what settings your extension will have.</li>
<li>
You can choose if each user has its own settings or if the
settings are global (set by the admin).
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.settings_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-md-2 col-sm-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.settings_data.type"
:options="settingsTypes"
></q-select>
</div>
<div class="col-md-10 col-sm-12 q-pt-sm">
<q-badge
v-if="extensionData.settings_data.type === 'user'"
outline
class="text-caption q-ml-md"
>Each user can set its own settings for this extension.</q-badge
>
<q-badge v-else outline class="text-caption q-ml-md"
>Settings are set by the admin and apply to all users of the
extension</q-badge
>
</div>
</div>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.settings_data.fields"
:hide-advanced="true"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="3"
:done="step > 3"
title="Owner Data"
icon="list"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep3"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('owner_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-input
v-model="extensionData.owner_data.name"
filled
label="Owner Table Name"
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
The owner of the extension manages this data. It can add, remove
and update instances of it.
</li>
<li>
Some fileds are present by default, like
<code>created_at</code>, <code>updated_at</code> and
<code>extra</code>.
</li>
</ul>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.owner_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="4"
:done="step > 4"
title="Client Data"
icon="blur_linear"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep4"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('client_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<!-- <q-toggle
v-model="extensionData.client_data.enabled"
label="Generate Client Table"
disable
size="md"
color="green"
/>
<br /> -->
<q-input
v-if="extensionData.client_data.enabled"
v-model="extensionData.client_data.name"
filled
label="Client Table Name"
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
This data is created by users of the extension. Usually when
they submit a form or make a payment.
</li>
<li>
The owner of the extension can view this data, but should not
modify it.
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.client_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.client_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="5"
:done="step > 5"
title="Public Pages"
icon="link"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep5"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('public_page')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.public_page.has_public_page"
label="Generate Public Page"
size="md"
color="green"
/>
<br />
<ul>
<li>
Most extensions have a public page that can be shared (this page
will still be accessible even if you have restricted access to
your LNbits install).
</li>
</ul>
</div>
</div>
<div v-if="extensionData.public_page.has_public_page">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page title</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a title for the public
page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.name"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page description</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a description for the
public page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.description"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page inputs</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the fields from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be shown as inputs in
the public page form.
</li>
<li>You can select multiple fields.</li>
<li>
A corresponding input field will be created for each
selected field.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
multiple
use-chips
v-model="extensionData.public_page.client_data_fields.public_inputs"
:options="extensionData.client_data.fields.map(f => f.name)"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Action Button</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the public page will have a button to
perform an action (e.g. generate a payment request).
</li>
<li>
The action will use the selected input fields from
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) as parameters.
</li>
<li>
A corresponding REST API endpoint will be created.
</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_action"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
</div>
<q-separator
v-if="extensionData.public_page.action_fields.generate_action"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.public_page.action_fields.generate_action">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Payment Logic</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the endpoint will create an invoice from
the submitted data and the UI will show the QR code
with the invoice.
</li>
<li>
A listener will be created to check for the pay event.
</li>
<li>You must map the fieds.</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_payment_logic"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6"></div>
</div>
<div
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
class="row q-col-gutter-md q-mt-md"
>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Wallet</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the wallet which
will generate the invoice and receive the payments.
</li>
<li>
Only fields with the type <code>Wallet</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.wallet_id"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Currency</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the currency
which will be used to for the amount.
</li>
<li>
Only fields with the type <code>Currency</code> will
be shown.
</li>
<li>Empty if you want to use sats.</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.currency"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Amount</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that represents the amount (in
the selected currency).
</li>
<li>
Only fields with the type <code>Integer</code> and
<code>Float</code> will be shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount_source"
:options="amountSource"
class="q-mr-sm"
></q-select>
</div>
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount"
:options="paymentActionAmountFields"
></q-select>
</div>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Paid Flag</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be set to true when
the invoice is paid.
</li>
<li>
Only fields with the type <code>Boolean</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.paid_flag"
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
</q-step>
<q-step
:name="6"
:done="step > 6"
title="Publish"
icon="publish"
style="min-height: 100px"
>
<div v-if="g.user.admin" class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.stub_version"
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
:options="extensionStubVersions.map(f => f.version)"
></q-select>
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<q-btn
@click="cleanCacheData()"
color="grey"
outline
label="Clean Cache"
class="q-ml-md"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
The extension builder uses caching to speed up the build
process.
</li>
<li>
This action clears old data and redownloads the Extension
Builder Stub release.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
<div v-if="g.user.admin" class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtensionAndDeploy()"
color="primary"
label="Build and Deploy (Admin Only)"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Installs the extension directly to this LNbits instance.
</li>
<li>
The extension will be enabled by default, and available to
all users.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<div class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtension()"
outline
color="gray"
label="Download Extension Zip"
icon="download"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
Builds the extension and downloads a zip file with the code.
You can then install it manually in your LNbits instance.
</q-tooltip>
</q-icon>
</div>
</div>
</div>
</q-step>
<template v-slot:navigation>
<q-separator></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
<q-btn
v-if="step == 1"
label="Clear All Data"
color="negative"
@click="clearAllData"
></q-btn>
<q-btn
v-else
flat
color="grey-8"
class="q-mr-sm"
@click="previousStep()"
label="Back"
icon="chevron_left"
/>
</div>
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
<q-stepper-navigation class="float-right">
<q-btn
v-if="step < 6"
@click="nextStep()"
color="primary"
label="Next"
></q-btn>
<template v-else>
<q-btn
@click="exportJsonData()"
color="primary"
label="Export JSON Data"
></q-btn>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Exports the config JSON so it can be later imported or
shared.
</li>
<li>
This JSON is also added to the zip in a file called
`builder.json`.
</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-stepper-navigation>
</div>
</div>
</template>
</q-stepper>
</div>
</div>
<div class="row q-col-gutter-md"></div>
{% endblock %}
@@ -1,150 +0,0 @@
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong v-text="$t('welcome_lnbits')"></strong>
<p><span v-text="$t('setup_su_account')"></span></p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request(
'PUT',
'/api/v1/auth/first_install',
null,
{
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
}
)
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
</script>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
-710
View File
@@ -1,710 +0,0 @@
{% extends "public.html" %} {% block scripts %}
<style>
:root {
--size: 100px;
--gap: 25px;
}
.btn-fixed-width {
/* width: 45%; */
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
max-width: 100%;
}
.marquee {
display: flex;
overflow: hidden;
user-select: none;
gap: var(--gap);
height: max-content;
mask-image: linear-gradient(
to right,
hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0)
);
}
.marquee__group {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--gap);
min-width: 100%;
animation: scroll-x 60s linear infinite;
}
.marquee:hover .marquee__group {
animation-play-state: paused;
}
.marquee__group div {
width: var(--size);
}
@keyframes scroll-x {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
</style>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page
class="q-px-md q-py-lg content-center"
:class="{'q-px-lg': $q.screen.gt.xs}"
>
{% block page %}
<div
class="row justify-center items-center"
style="min-height: calc(100vh / 1.618)"
>
<div
class="full-width"
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
>
<div class="row q-mb-md">
<div class="col-12">
<div>
<h5
class="q-my-none"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TITLE}}
</h5>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div> -->
</div>
</div>
</div>
<div class="row">
<q-badge
v-if="isAccessTokenExpired"
class="q-mx-auto q-mb-md"
color="primary"
rounded
>
<div class="text-h6">
<span v-text="$t('session_has_expired')"></span>
</div>
</q-badge>
<q-card bordered class="full-width q-py-md">
<div class="row">
<div
class="col-12"
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
in LNBITS_AUTH_METHODS)%}
<div class="full-height content-center">
<q-card-section>
<div class="text-body1">
<span v-text="$t('claim_desc')"></span>
</div>
</q-card-section>
<q-card-section>
<q-btn
unelevated
color="primary"
@click="processing"
type="a"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
</q-card-section>
</div>
{%else%}
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowedRegister"
:auth-methods="LNBITS_AUTH_METHODS"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
v-model:password_2="passwordRepeat"
v-model:reset-key="reset_key"
@login="login"
@register="register"
@reset="reset"
>
<div
class="text-center text-grey-6"
v-if="authAction !== 'reset'"
>
<p
v-if="authAction === 'login' && allowedRegister"
class="q-mb-none"
>
Not registered?
<a
href="#"
class="text-secondary cursor-pointer"
@click.prevent="showRegister('username-password')"
>Create an Account</a
>
</p>
<p
v-else-if="authAction === 'login' && !allowedRegister"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span>
<a
href="#"
class="text-secondary cursor-pointer q-ml-sm"
@click.prevent="showLogin('username-password')"
v-text="$t('login')"
></a>
</p>
</div>
</username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only
:allowed_new_users="allowedRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
:auth-method="authMethod"
@show-login="showLogin"
@show-register="showRegister"
@login-usr="loginUsr"
@create-wallet="createWallet"
>
</user-id-only>
{%endif%} {% endif %}
</div>
<div
class="col-sm-5 col-lg-6 gt-xs"
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
>
<div class="full-height flex flex-center q-pa-lg">
<q-img
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
:ratio="1"
width="250px"
></q-img>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
class=""
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
class=""
></q-btn>
</div>
</div>
{% if AD_SPACE_ENABLED and AD_SPACE %}
<div class="q-pt-md full-width">
<div class="row justify-center q-mb-xl">
<div class="full-width text-center">
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
</div>
<div class="flex flex-center columm">
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="flex flex-center column q-pr-sm">
<a href="{{ AD[0] }}">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 420px"
class="full-width"
/>
<img
v-else
src="{{ AD[2] }}"
style="max-width: 420px"
class="full-width"
/>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
File diff suppressed because it is too large Load Diff
-165
View File
@@ -1,165 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
%} {% endblock %}
@@ -1,379 +0,0 @@
<q-tab-panel name="channels">
<q-dialog v-model="connectPeerDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="connectPeerDialog.data.uri"
label="Node URI"
hint="pubkey@host:port"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('connect')"
color="primary"
@click="connectPeer"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="setFeeDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<label class="text-h6">Set Channel Fee</label>
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
<q-separator></q-separator>
<q-form class="q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_ppm"
label="Fee Rate PPM"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_base_msat"
label="Fee Base msat"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('set')"
color="primary"
@click="setChannelFee(setFeeDialog.channel_id)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="openChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="openChannelDialog.data.peer_id"
label="Peer ID"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.funding_amount"
label="Funding Amount"
></q-input>
<q-expansion-item icon="warning" label="Advanced">
<q-card>
<q-card-section>
<div class="column q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.push_amount"
label="Push Amount"
hint="This gifts sats to the other side!"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.fee_rate"
label="Fee Rate"
></q-input>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
:label="$t('open')"
color="primary"
@click="openChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="closeChannelDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<div>
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('close')"
color="primary"
@click="closeChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-card-section class="q-pa-none">
<div class="row q-col-gutter-lg">
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="q-gutter-y-sm">
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
<q-input
filled
dense
clearable
v-model="channels.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-select
dense
size="sm"
style="min-width: 200px"
filled
multiple
clearable
v-model="stateFilters"
:options="this.states"
class="col-auto"
></q-select>
<q-btn
unelevated
color="primary"
size="md"
class="col-auto"
@click="showOpenChannelDialog()"
>
Open channel
</q-btn>
</div>
<div>
<div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance
:balance="this.totalBalance"
></lnbits-channel-balance>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="q-pb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="props.row.name"></div>
<div class="text-caption" v-if="props.row.peer_id">
<span>Peer ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.peer_id)"
></q-btn>
</div>
<div class="text-caption col-grow">
<span>Fees</span>
<q-btn
size="xs"
flat
dense
icon="settings"
@click="showSetFeeDialog(props.row.id)"
></q-btn>
<span v-if="props.row.fee_ppm">
<span v-text="props.row.fee_ppm"></span> ppm,
<span v-text="props.row.fee_base_msat"></span> msat
</span>
</div>
<div class="text-caption" v-if="props.row.id">
<span>Channel ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
</div>
<div class="text-caption" v-if="props.row.short_id">
<span v-text="props.row.short_id"></span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.short_id)"
></q-btn>
</div>
<q-badge
rounded
:color="states.find(s => s.value == props.row.state)?.color"
v-text="states.find(s => s.value == props.row.state)?.label"
>
</q-badge>
<q-btn
:disable='props.row.state !== "active"'
flat
dense
size="md"
@click="showCloseChannelDialog(props.row)"
icon="cancel"
color="pink"
></q-btn>
</div>
<lnbits-channel-balance
:balance="props.row.balance"
:color="props.row.color"
></lnbits-channel-balance>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="column q-gutter-y-sm">
<div
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
>
<div class="col-grow text-h6 q-my-none">Peers</div>
<q-input
filled
dense
clearable
v-model="peers.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-btn
class="col-auto"
color="primary"
@click="connectPeerDialog.show = true"
>
Connect Peer
</q-btn>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="row no-wrap items-center q-gutter-sm">
<div class="q-my-sm col-grow">
<div
class="text-subtitle1 text-bold"
v-text="props.row.alias"
></div>
<div class="row items-center q-gutter-sm">
<q-badge
:style="`background-color: #${props.row.color}`"
class="text-bold"
v-text="'#'+props.row.color"
>
</q-badge>
<div
class="text-bold"
v-text="shortenNodeId(props.row.id)"
></div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row)"
></q-btn>
</div>
</div>
<q-btn
unelevated
color="primary"
@click="showOpenChannelDialog(props.row.id)"
>
Open channel
</q-btn>
<q-btn
flat
dense
size="md"
@click="disconnectPeer(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,68 +0,0 @@
<q-tab-panel name="dashboard">
<q-card-section class="q-pa-none">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Fees collected"
:msat="this.info.fees?.total_msat"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Balance"
:btc="this.info.onchain_balance_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Confirmed"
:btc="this.info.onchain_confirmed_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats
:stats="this.channel_stats"
></lnbits-channel-stats>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,314 +0,0 @@
<q-tab-panel name="transactions">
<q-card-section class="q-pa-none">
<q-dialog v-model="transactionDetailsDialog.show" position="top">
<q-card class="my-card">
<q-card-section>
<div class="text-center q-mb-lg">
<div
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
>
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
</div>
<div class="row q-my-md">
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
<div class="col-9 text-wrap mono">
<span
v-text="transactionDetailsDialog.data.payment_hash"
></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div
class="row"
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
>
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono">
<span v-text="transactionDetailsDialog.data.preimage"></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
</div>
</div>
<div
v-if="transactionDetailsDialog.data.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(transactionDetailsDialog.data.bolt11)"
:label="$t('copy_invoice')"
class="q-mt-sm"
></q-btn>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<div class="row q-col-gutter-md q-pb-lg"></div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Payments</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="paymentsTable.data"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@request="getPayments"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_made"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-dialog
v-model="props.row.expand"
:props="props"
position="top"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-td>
</template>
<template v-slot:body-cell-date="props">
<q-td auto-width key="date" :props="props">
<lnbits-date :ts="props.row.time"></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-destination="props">
<q-td auto-width key="destination">
<div class="row items-center justify-between no-wrap">
<q-badge
:style="`background-color: #${props.row.destination?.color}`"
class="text-bold"
v-text="props.row.destination?.alias"
></q-badge>
<div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(info.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row.destination)"
></q-btn>
</div>
</div>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Invoices</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="invoiceTable.data"
:columns="invoiceTable.columns"
v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_received"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-paid_at="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.paid_at"
:ts="props.row.paid_at"
></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-expiry="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.expiry"
:ts="props.row.expiry"
></lnbits-date>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
-46
View File
@@ -1,46 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<q-dialog v-model="nodeInfoDialog.show" position="top">
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
</q-dialog>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-tab
name="dashboard"
:label="$t('dashboard')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="channels"
:label="$t('channels')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="transactions"
:label="$t('transactions')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "node/_tab_dashboard.html" %} {% include
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
%}
</q-tab-panels>
</q-form>
</q-card>
</div>
</div>
{% endblock %}
-133
View File
@@ -1,133 +0,0 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
</div>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
LNbits,
msg: 'hello'
}
},
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
wallet: {},
tab: 'dashboard',
payments: 1000,
info: {},
channel_stats: {},
channels: [],
activeBalance: {},
ranks: {},
peers: [],
connectPeerDialog: {
show: false,
data: {}
},
openChannelDialog: {
show: false,
data: {}
},
closeChannelDialog: {
show: false,
data: {}
},
nodeInfoDialog: {
show: false,
data: {}
},
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
]
}
},
created: function () {
this.getInfo()
this.get1MLStats()
},
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
},
api: function (method, url, data) {
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
},
getInfo: function () {
this.api('GET', '/info', {}).then(response => {
this.info = response.data
this.channel_stats = response.data.channel_stats
})
},
get1MLStats: function () {
this.api('GET', '/rank', {}).then(response => {
this.ranks = response.data
})
}
}
})
</script>
{% endblock %}
-432
View File
@@ -1,432 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col"></div>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentStatus"
:label="$t('payments_status_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentTags"
:label="$t('payments_tag_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalance"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showWalletsSize"
:label="$t('payments_wallets_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalanceInOut"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentCountInOut"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<q-btn icon="event" outline flat>
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</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-btn>
<q-separator vertical class="q-ma-sm"></q-separator>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#server"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div v-show="!showDetails">
<div class="row q-col-gutter-md justify-center q-mb-md">
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_status')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tags')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalance"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showWalletsSize"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalanceInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_balance_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentCountInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_count_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="payment_hash"
:rows="payments"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
:filter="paymentsTable.search"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchPaymentsBy()"
@update:model-value="searchPaymentsBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchPaymentsBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-btn
v-else-if="['status'].includes(col.name)"
flat
dense
:label="$q.screen.gt.md ? 'Status' : null"
icon="filter_alt"
color="grey"
class="text-capitalize"
>
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-select
v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'status'">
<q-tooltip
><span v-text="$t('payment_details')"></span
></q-tooltip>
<q-icon
@click="showDetailsToggle(props.row)"
v-if="props.row.status === 'success'"
size="14px"
:name="props.row.outgoing ? 'call_made' : 'call_received'"
:color="props.row.outgoing ? 'pink' : 'green'"
class="cursor-pointer"
></q-icon>
<q-icon
v-else-if="props.row.status === 'pending'"
@click="showDetailsToggle(props.row)"
name="downloading"
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
color="grey"
class="cursor-pointer"
></q-icon>
<q-icon
v-else
@click="showDetailsToggle(props.row)"
name="warning"
color="yellow"
class="cursor-pointer"
></q-icon>
</div>
<div v-else-if="col.name == 'created_at'">
<div>
<q-tooltip anchor="top middle">
<span v-text="formatDate(props.row.created_at)"></span>
</q-tooltip>
<span v-text="props.row.timeFrom"> </span>
</div>
</div>
<div
v-else-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip anchor="top middle">Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name], col.max_length)">
</span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
<div v-show="showDetails">
<q-card>
<q-card-section class="flex">
<div>
<q-btn
flat
round
icon="arrow_back"
class="q-mr-md"
@click="showDetailsToggle(null)"
></q-btn>
</div>
<div class="self-center text-h6 text-weight-bolder text-grey-5">
<span v-text="$t('payment_details_back')"></span>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="text-h6">
<q-item>
<q-item-section avatar class="">
<q-icon color="primary" name="receipt" size="44px"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>
<div class="text-h6">
<span v-text="$t('payment_details')"></span>
</div>
</q-item-label>
<q-item-label caption v-text="$t('payment_details_desc')">
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
<q-card-section>
<q-list separator>
<q-item v-for="(value, key) in paymentDetails" :key="key">
<q-item-section>
<q-item-label v-text="key"></q-item-label>
<q-item-label
caption
v-text="value"
style="word-wrap: break-word"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
v-show="value"
icon="content_copy"
flat
class="cursor-pointer q-ml-sm"
@click="copyText(value)"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
</q-item-section>
<!-- <q-separator></q-separator> -->
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
{% endblock %}
@@ -1,44 +0,0 @@
<q-dialog v-model="createWalletDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Wallet</strong>
<div class="row">
<div class="col-12">
<div class="row q-mt-lg">
<div class="col">
<q-input
v-model="createWalletDialog.data.name"
:label='$t("name_your_wallet")'
filled
dense
class="q-mb-md"
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-select
filled
dense
v-model="createWalletDialog.data.currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
@click="createWallet()"
unelevated
color="primary"
type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
</q-card>
</q-dialog>
@@ -1,196 +0,0 @@
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
v-if="activeUser.data.id"
@click="updateUser()"
color="primary"
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-else
@click="createUser()"
:label="$t('create_account')"
color="primary"
class="float-right"
></q-btn>
</div>
</div>
<q-card v-if="activeUser.show" class="q-pa-md">
<q-card-section>
<div class="text-h6">
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
<span v-else v-text="$t('create_account')"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
v-if="activeUser.data.id"
v-model="activeUser.data.id"
:label="$t('user_id')"
filled
dense
readonly
:type="activeUser.data.showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
dense
flat
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.username"
:label="$t('username')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-toggle
size="xs"
v-if="!activeUser.data.id"
color="secondary"
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
</q-toggle>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password"
:type="activeUser.data.showPassword ? 'text': 'password'"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password_repeat"
:type="activeUser.data.showPassword ? 'text': 'password'"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.pubkey"
:label="'Nostr '+ $t('pubkey')"
filled
dense
class="q-mb-md"
>
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input>
<q-input
v-model="activeUser.data.email"
:label="$t('email')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-card-section v-if="activeUser.data.extra">
<q-input
v-model="activeUser.data.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.provider"
:label="$t('auth_provider')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-select
filled
dense
v-model="activeUser.data.extensions"
multiple
label="User extensions"
:options="g.extensions"
></q-select>
</q-card-section>
<q-card-section v-if="activeUser.data.id">
<q-btn
@click="resetPassword(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('reset_password')"
icon="refresh"
color="primary"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
@click="deleteUser(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
<q-tooltip>Delete User</q-tooltip></q-btn
>
</q-card-section>
</q-card>
@@ -1,190 +0,0 @@
<div v-if="paymentPage.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="paymentPage.show = false"
:label="$t('back')"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-card-section>
<payment-list :wallet="paymentsWallet" />
</q-card-section>
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<h2 class="text-h6 q-mb-md">Wallets</h2>
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width v-if="g.user.super_user"></q-th>
<q-th auto-width></q-th>
<q-th
auto-width
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width v-if="g.user.super_user">
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn
round
icon="menu"
size="sm"
color="secondary"
@click="showPayments(props.row.id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
<q-btn
round
v-if="props.row.deleted"
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
</q-btn>
<q-btn
icon="link"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyWalletLink(props.row.id)"
>
<q-tooltip>Copy Wallet Link</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<span
v-text="props.row.name"
v-if="!props.row.editable"
:class="props.row.deleted ? 'text-strike' : 'cursor-pointer'"
@click="props.row.editable = true && !props.row.deleted"
></span>
<q-input
v-else
@keydown.enter="updateWallet(props.row)"
v-model="props.row.name"
size="xs"
flat
dense
>
<template v-slot:append>
<q-btn
@click="updateWallet(props.row)"
round
dense
size="xs"
flat
icon="send"
/>
</template>
</q-input>
</q-td>
<q-td auto-width>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<span
v-text="props.row.id"
:class="props.row.deleted ? 'text-strike' : ''"
></span>
</q-td>
<q-td auto-width v-text="props.row.currency"></q-td>
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
-186
View File
@@ -1,186 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col">
{% include "users/_manageWallet.html" %}
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
</div>
<div v-else-if="activeWallet.show">
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAccountPage()"
:label="$t('create_account')"
color="primary"
>
</q-btn>
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#users"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="users"
:columns="usersTable.columns"
v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@request="fetchUsers"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchUserBy(col.name)"
dense
type="text"
filled
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchUserBy(col.name)"
class="cursor-pointer"
/>
</template>
</q-input>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td>
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
<span v-text="$t('update_account')"></span>
</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-toggle
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
v-if="props.row.is_super_user"
icon="verified"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="list"
size="sm"
color="secondary"
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td v-text="props.row.email"></q-td>
<q-td>
<q-btn
v-if="props.row.pubkey"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.pubkey)"
>
<q-tooltip>Copy Public Key</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.pubkey)"></span>
</q-td>
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td v-text="props.row.transaction_count"></q-td>
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
{% endblock %}
+2 -98
View File
@@ -1,19 +1,14 @@
import os
import time
from http import HTTPStatus
from pathlib import Path
from shutil import make_archive, move
from shutil import make_archive
from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO
from urllib.parse import urlparse
import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File
from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_admin_notification,
@@ -23,7 +18,6 @@ from lnbits.core.services import (
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
@@ -172,93 +166,3 @@ async def api_download_backup() -> FileResponse:
return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
)
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+174
View File
@@ -0,0 +1,174 @@
import base64
from http import HTTPStatus
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from lnbits.core.crud.assets import (
delete_user_asset,
get_asset_info,
get_public_asset,
get_public_asset_info,
get_user_asset,
get_user_asset_info,
get_user_assets,
update_user_asset_info,
)
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import User
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_user_exists,
optional_user_id,
parse_filters,
)
asset_router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
upload_file_param = File(...)
@asset_router.get(
"/paginated",
name="Get user assets",
summary="Get paginated list user assets",
)
async def api_get_user_assets(
user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]:
return await get_user_assets(user.id, filters=filters)
@asset_router.get(
"/{asset_id}",
name="Get user asset",
summary="Get user asset by ID",
)
async def api_get_asset(
asset_id: str,
user: User = Depends(check_user_exists),
) -> AssetInfo:
asset_info = await get_user_asset_info(user.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_info
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
)
async def api_get_asset_binary(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset = None
if user_id:
asset = await get_user_asset(user_id, asset_id)
if not asset:
asset = await get_public_asset(asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
"/{asset_id}/thumbnail",
name="Get user asset thumbnail",
summary="Get user asset thumbnail data by ID",
)
async def api_get_asset_thumbnail(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset_info = None
if user_id:
asset_info = await get_user_asset_info(user_id, asset_id)
if not asset_info:
asset_info = await get_public_asset_info(asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@asset_router.put(
"/{asset_id}",
name="Update user asset",
summary="Update user asset by ID",
)
async def api_update_asset(
asset_id: str,
data: AssetUpdate,
user: User = Depends(check_user_exists),
) -> AssetInfo:
if user.admin:
asset_info = await get_asset_info(asset_id)
else:
asset_info = await get_user_asset_info(user.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
asset_info.name = data.name or asset_info.name
asset_info.is_public = (
asset_info.is_public if data.is_public is None else data.is_public
)
await update_user_asset_info(asset_info)
return asset_info
@asset_router.post(
"",
name="Upload",
summary="Upload user assets",
)
async def api_upload_asset(
user: User = Depends(check_user_exists),
file: UploadFile = upload_file_param,
public_asset: bool = False,
) -> AssetInfo:
asset = await create_user_asset(user.id, file, public_asset)
asset_info = await get_user_asset_info(user.id, asset.id)
if not asset_info:
raise ValueError("Failed to retrieve asset info after upload.")
return asset_info
@asset_router.delete(
"/{asset_id}",
name="Delete user asset",
summary="Delete user asset by ID",
)
async def api_delete_asset(
asset_id: str,
user: User = Depends(check_user_exists),
) -> SimpleStatus:
asset = await get_user_asset(user.id, asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
await delete_user_asset(user.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")
+2 -10
View File
@@ -25,6 +25,7 @@ from lnbits.core.models.users import (
UpdateAccessControlList,
)
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.helpers import (
create_access_token,
@@ -422,15 +423,6 @@ async def update(
) -> User | None:
if data.user_id != 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)
if not account:
@@ -441,7 +433,7 @@ async def update(
if data.extra:
account.extra = data.extra
await update_account(account)
await update_user_account(account)
return await get_user_from_account(account)
+81
View File
@@ -36,12 +36,14 @@ from lnbits.decorators import (
check_admin,
check_user_exists,
)
from lnbits.settings import settings
from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_db_version,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_user_extension,
@@ -492,3 +494,82 @@ async def delete_extension_db(ext_id: str):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'",
) from exc
# TODO: create a response model for this
@extension_router.get("/all")
async def extensions(user: User = Depends(check_user_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
}
for ext in installable_exts
]
return extension_data
+5 -17
View File
@@ -1,9 +1,8 @@
import os
import shutil
from hashlib import sha256
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from lnbits.core.models import (
@@ -28,9 +27,9 @@ from lnbits.core.services.extensions_builder import (
)
from lnbits.decorators import (
check_admin,
check_extension_builder,
check_user_exists,
)
from lnbits.settings import settings
from ..crud import (
create_user_extension,
@@ -47,19 +46,12 @@ extension_builder_router = APIRouter(
@extension_builder_router.post(
"/zip",
summary="Build and download extension zip.",
dependencies=[Depends(check_extension_builder)],
description="""
This endpoint generates a zip file for the extension based on the provided data.
""",
)
async def api_build_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
) -> FileResponse:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
async def api_build_extension(data: ExtensionData) -> FileResponse:
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
release, build_dir = await build_extension_from_data(data, stub_ext_id)
@@ -132,16 +124,12 @@ async def api_deploy_extension(
@extension_builder_router.post(
"/preview",
summary="Build and preview the extension ui.",
dependencies=[Depends(check_extension_builder)],
)
async def api_preview_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
) -> SimpleStatus:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
stub_ext_id = "extension_builder_stub"
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
await build_extension_from_data(data, stub_ext_id, working_dir_name)
+35 -335
View File
@@ -1,35 +1,29 @@
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
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
from lnbits.core.services import create_invoice, create_user_account
from lnbits.core.services.extensions import get_valid_extensions
from lnbits.decorators import check_admin, check_user_exists
from lnbits.decorators import (
check_admin,
check_admin_ui,
check_extension_builder,
check_first_install,
check_user_exists,
)
from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_db_versions,
get_installed_extensions,
get_user,
get_wallet,
)
from ..crud import get_user
generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False
@@ -41,251 +35,23 @@ async def favicon():
return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse(
request, "core/index.html", {"lnurl": lightning}
)
@generic_router.get(
"/account",
response_class=HTMLResponse,
description="show account page",
)
async def account(
request: Request,
user: User = Depends(check_user_exists),
):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request),
},
)
@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(
"/wallets",
response_class=HTMLResponse,
description="show wallets page",
)
async def wallets(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/wallets.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/first_install", response_class=HTMLResponse)
async def first_install(request: Request):
if not settings.first_install:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Super user account has already been configured.",
)
return template_renderer().TemplateResponse(
request,
"core/first_install.html",
)
@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")
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
# TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.json(),
"extension_data": extension_data,
"extension_builder_enabled": user.admin
or settings.lnbits_extensions_builder_activate_non_admins,
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
)
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
return template_renderer().TemplateResponse(
request,
"core/extensions_builder.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
name="extensions builder",
response_class=HTMLResponse,
dependencies=[Depends(check_extension_builder)],
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
page_name: str | None = None,
user: User = Depends(check_user_exists),
):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
) -> HTMLResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
@@ -359,7 +125,7 @@ async def manifest(request: Request, usr: str):
"src": (
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",
"type": "image/png",
@@ -426,104 +192,38 @@ async def manifest(request: Request, usr: str):
}
@generic_router.get("/node", response_class=HTMLResponse)
async def node(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
funding_source = get_funding_source()
_, balance = await funding_source.status()
@generic_router.get("/payments")
@generic_router.get("/wallet")
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
@generic_router.get("/admin", dependencies=admin_ui_checks)
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer().TemplateResponse(
request,
"node/index.html",
"index.html",
{
"user": user.json(),
"balance": balance,
"wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/node/public", response_class=HTMLResponse)
async def node_public(request: Request):
if not settings.lnbits_public_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"node/public.html",
{
"balance": balance,
},
)
@generic_router.get("/admin", response_class=HTMLResponse)
async def admin_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"admin/index.html",
{
"user": user.json(),
"balance": balance,
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/users", response_class=HTMLResponse)
async def users_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return template_renderer().TemplateResponse(
"users/index.html",
{
"request": request,
"user": user.json(),
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/audit", response_class=HTMLResponse)
async def audit_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_audit_enabled:
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
return template_renderer().TemplateResponse(
"audit/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/payments", response_class=HTMLResponse)
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/")
@generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "index_public.html")
@generic_router.get("/uuidv4/{hex_value}")
+7 -6
View File
@@ -6,16 +6,17 @@ from fastapi import (
Depends,
HTTPException,
)
from lnurl import LnurlResponseException
from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import (
from lnurl import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayResponse,
LnurlResponseModel,
LnurlResponseException,
LnurlWithdrawResponse,
)
from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import LnurlResponseModel
from loguru import logger
from lnbits.core.models import Payment
@@ -38,7 +39,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlResponseException as exc:
except LnurlException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
+2 -3
View File
@@ -3,7 +3,6 @@ from http import HTTPStatus
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.settings import settings
@@ -154,7 +153,7 @@ async def api_get_payments(
) -> Page[NodePayment] | None:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
HTTPStatus.SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI",
)
return await node.get_payments(filters)
@@ -167,7 +166,7 @@ async def api_get_invoices(
) -> Page[NodeInvoice] | None:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
HTTPStatus.SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI",
)
return await node.get_invoices(filters)
+31
View File
@@ -15,7 +15,9 @@ from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallets_stats,
update_payment,
)
from lnbits.core.crud.users import get_account
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
@@ -32,6 +34,7 @@ from lnbits.core.models import (
SettleInvoice,
SimpleStatus,
)
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
from lnbits.decorators import (
@@ -176,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)
@@ -247,6 +256,7 @@ async def api_payments_create(
wallet_id=wallet_id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
)
return payment
@@ -260,6 +270,27 @@ async def api_payments_create(
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")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
+66 -4
View File
@@ -8,10 +8,25 @@ from fastapi import (
HTTPException,
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.crud.wallets import (
create_wallet,
get_wallets_paginated,
)
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.wallets import WalletsFilters
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import (
WalletsFilters,
WalletSharePermission,
WalletType,
)
from lnbits.core.services.wallets import (
create_lightning_shared_wallet,
delete_wallet_share,
invite_to_wallet,
reject_wallet_invitation,
update_wallet_share_permissions,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_user_exists,
@@ -22,7 +37,6 @@ from lnbits.decorators import (
from lnbits.helpers import generate_filter_params_openapi
from ..crud import (
create_wallet,
delete_wallet,
get_wallet,
update_wallet,
@@ -62,6 +76,35 @@ async def api_wallets_paginated(
return page
@wallet_router.put("/share/invite")
async def api_invite_wallet_share(
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WalletSharePermission:
return await invite_to_wallet(key_info.wallet, data)
@wallet_router.delete("/share/invite/{share_request_id}")
async def api_reject_wallet_invitation(
share_request_id: str, invited_user: User = Depends(check_user_exists)
) -> SimpleStatus:
await reject_wallet_invitation(invited_user.id, share_request_id)
return SimpleStatus(success=True, message="Invitation rejected.")
@wallet_router.put("/share")
async def api_accept_wallet_share_request(
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WalletSharePermission:
return await update_wallet_share_permissions(key_info.wallet, data)
@wallet_router.delete("/share/{share_request_id}")
async def api_delete_wallet_share_permissions(
share_request_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> SimpleStatus:
return await delete_wallet_share(key_info.wallet, share_request_id)
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
@@ -70,6 +113,7 @@ async def api_update_wallet_name(
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = new_name
await update_wallet(wallet)
return {
"id": wallet.id,
@@ -124,6 +168,7 @@ async def api_update_wallet(
wallet.extra.color = color or wallet.extra.color
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
@@ -147,4 +192,21 @@ async def api_create_wallet(
data: CreateWallet,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
if data.wallet_type == WalletType.LIGHTNING:
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
if data.wallet_type == WalletType.LIGHTNING_SHARED:
if not data.shared_wallet_id:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Shared wallet ID is required for shared wallets.",
)
return await create_lightning_shared_wallet(
user_id=key_info.wallet.user,
source_wallet_id=data.shared_wallet_id,
)
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Unknown wallet type: {data.wallet_type}.",
)
+92 -25
View File
@@ -130,6 +130,12 @@ class Compat:
return "BIGINT"
return "INT"
@property
def blob(self) -> str:
if self.type in {POSTGRES}:
return "BYTEA"
return "BLOB"
def timestamp_placeholder(self, key: str) -> str:
return compat_timestamp_placeholder(key)
@@ -216,18 +222,34 @@ class Connection(Compat):
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
table_name: str | None = None,
) -> Page[TModel]:
"""
Parameters:
query: The main SQL query string to execute for data retrieval.
where: list of additional WHERE clause conditions to filter results.
values: dictionary of parameter values to be used in the SQL query.
filters: object for advanced filtering, sorting, and pagination logic.
model: pydantic model type to map query results into model instances.
group_by: list of column names to group results by in the SQL query.
table_name: if provided some optimisations can be applied.
"""
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)
group_by_string = ""
if group_by:
for field in group_by:
if not re.fullmatch(
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
):
if not _valid_sql_name(field):
raise ValueError("Value for GROUP BY is invalid")
group_by_string = f"GROUP BY {', '.join(group_by)}"
@@ -245,16 +267,17 @@ class Connection(Compat):
if rows:
# no need for extra query if no pagination is specified
if filters.offset or filters.limit:
result = await self.execute(
f"""
SELECT COUNT(*) as count FROM (
if table_name:
count_query = f"SELECT COUNT(*) as count FROM {table_name} {clause}" # noqa: S608
else:
count_query = f"""SELECT COUNT(*) as count
FROM (
{query}
{clause}
{group_by_string}
) as count
""", # noqa: S608
parsed_values,
)
) as count""" # noqa: S608
result = await self.execute(count_query, parsed_values)
row = result.mappings().first()
result.close()
count = int(row.get("count", 0))
@@ -387,9 +410,12 @@ class Database(Compat):
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
table_name: str | None = None,
) -> Page[TModel]:
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by)
return await conn.fetch_page(
query, where, values, filters, model, group_by, table_name
)
async def execute(self, query: str, values: dict | None = None):
async with self.connect() as conn:
@@ -425,6 +451,8 @@ class Operator(Enum):
INCLUDE = "in"
EXCLUDE = "ex"
LIKE = "like"
EVERY = "every"
ANY = "any"
@property
def as_sql(self):
@@ -444,7 +472,7 @@ class Operator(Enum):
return ">="
elif self == Operator.LE:
return "<="
elif self == Operator.LIKE:
elif self in {Operator.LIKE, Operator.EVERY, Operator.ANY}:
return "LIKE"
else:
raise ValueError("Unknown SQL Operator")
@@ -466,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
@@ -475,6 +504,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
def parse_query(
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[operator]
# e.g. name[eq]
@@ -491,11 +522,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__:
compare_field = model.__fields__[field]
values: dict = {}
for raw_value in raw_values:
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):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values[f"{field}__{i}"] = validated
values[f"{field}__{index}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -503,15 +537,24 @@ 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"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(f":{key}")
else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
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})"
class Filters(BaseModel, Generic[TFilterModel]):
@@ -527,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")
@@ -548,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
@@ -575,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:
@@ -587,12 +632,26 @@ class Filters(BaseModel, Generic[TFilterModel]):
for key, value in page_filter.values.items():
if page_filter.op == Operator.LIKE:
values[key] = f"%{value}%"
elif page_filter.op in {Operator.EVERY, Operator.ANY}:
values[key] = f"""%"{value}"%"""
else:
values[key] = value
if self.search and self.model:
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):
if isinstance(o, Enum):
return o.value
return super().default(o)
def insert_query(table_name: str, model: BaseModel) -> str:
"""
@@ -648,7 +707,7 @@ def model_to_dict(model: BaseModel) -> dict:
or type_ is dict
or get_origin(outertype_) is list
):
_dict[key] = json.dumps(value)
_dict[key] = json.dumps(value, cls=DbJsonEncoder)
continue
_dict[key] = value
@@ -722,3 +781,11 @@ def _safe_load_json(value: str) -> dict:
# DB is corrupted if it gets here
logger.error(f"Failed to decode JSON: '{value}'")
return {}
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (
re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", name)
is not None
)
+25
View File
@@ -359,3 +359,28 @@ def url_for_interceptor(original_method):
# Upgraded extensions modify the path.
# This interceptor ensures that the path is normalized.
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
async def check_admin_ui() -> None:
if not settings.lnbits_admin_ui:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Admin UI is disabled."
)
async def check_extension_builder(
user: Annotated[User, Depends(check_user_exists)],
) -> None:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
async def check_first_install():
if not settings.first_install:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Super user account has already been configured.",
)
+25 -17
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
@@ -69,7 +70,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["static_url_for"] = static_url_for
window_settings = {
"AD_SPACE": settings.lnbits_ad_space.split(","),
"AD_SPACE": settings.lnbits_ad_space,
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
@@ -97,15 +98,20 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
"LNBITS_DEFAULT_THEME": settings.lnbits_default_theme,
"LNBITS_DEFAULT_BORDER": settings.lnbits_default_border,
"LNBITS_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
"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
@@ -198,6 +204,11 @@ def is_valid_username(username: str) -> bool:
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:
if len(external_id) > 256:
return False
@@ -353,17 +364,6 @@ def normalize_path(path: str | None) -> str:
return "/" + "/".join(path_segments(path))
def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
image_folder = Path(settings.lnbits_data_folder, directory)
file_path = image_folder / filename
# Prevent dir traversal attack
if image_folder.resolve() not in file_path.resolve().parents:
raise ValueError("Unsafe filename.")
# Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1]
return file_path.resolve()
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
if add_proto:
@@ -397,3 +397,11 @@ def is_snake_case(v: str) -> bool:
def lowercase_first_letter(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
def sha256s(value: str) -> str:
"""
SHA256 applied on a string value.
Returns the hex as a string.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
+13 -3
View File
@@ -280,8 +280,11 @@ class ThemesSettings(LNbitsSettings):
class OpsSettings(LNbitsSettings):
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
lnbits_hide_api: bool = Field(default=False)
lnbits_upload_size_bytes: int = Field(default=512_000, ge=0) # 500kb
lnbits_upload_allowed_types: list[str] = Field(
class AssetSettings(LNbitsSettings):
lnbits_max_asset_size_mb: float = Field(default=2.5, ge=0.0)
lnbits_assets_allowed_mime_types: list[str] = Field(
default=[
"image/png",
"image/jpeg",
@@ -297,6 +300,12 @@ class OpsSettings(LNbitsSettings):
"heics",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
lnbits_asset_thumbnail_height: int = Field(default=128, ge=0)
lnbits_asset_thumbnail_format: str = Field(default="png")
lnbits_max_assets_per_user: int = Field(default=1, ge=0)
lnbits_assets_no_limit_users: list[str] = Field(default=[])
class FeeSettings(LNbitsSettings):
@@ -429,7 +438,7 @@ class NotificationsSettings(LNbitsSettings):
lnbits_notification_settings_update: bool = Field(default=True)
lnbits_notification_credit_debit: bool = Field(default=True)
notification_balance_delta_changed: bool = Field(default=True)
notification_balance_delta_threshold_sats: int = Field(default=1, ge=0)
lnbits_notification_server_start_stop: bool = Field(default=True)
lnbits_notification_watchdog: bool = Field(default=False)
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
@@ -867,6 +876,7 @@ class EditableSettings(
ExtensionsSettings,
ThemesSettings,
OpsSettings,
AssetSettings,
FeeSettings,
ExchangeProvidersSettings,
SecuritySettings,
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
+10 -10
View File
File diff suppressed because one or more lines are too long
+114
View File
@@ -185,6 +185,11 @@ body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
}
body.gradient-bg {
background-image: linear-gradient(to bottom right, #fff, var(--q-primary));
background-attachment: fixed;
}
body.gradient-bg.body--dark {
background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
background-attachment: fixed;
}
@@ -215,6 +220,53 @@ body.body--dark .q-drawer {
backdrop-filter: blur(6px);
}
:root {
--size: 100px;
--gap: 25px;
}
.home .btn-fixed-width {
/* width: 45%; */
}
.home .wrapper {
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
max-width: 100%;
}
.home .marquee {
display: flex;
overflow: hidden;
user-select: none;
gap: var(--gap);
height: max-content;
mask-image: linear-gradient(to right, hsla(0, 0%, 0%, 0), hsl(0, 0%, 0%) 20%, hsl(0, 0%, 0%) 80%, hsla(0, 0%, 0%, 0));
}
.home .marquee__group {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--gap);
min-width: 100%;
animation: scroll-x 60s linear infinite;
}
.home .marquee:hover .marquee__group {
animation-play-state: paused;
}
.home .marquee__group div {
width: var(--size);
}
@keyframes scroll-x {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
[v-cloak] {
display: none;
}
@@ -341,3 +393,65 @@ video {
.q-dialog__inner--minimized {
padding: 12px;
}
.first-install {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.first-install .grid {
display: block;
}
.first-install .hero-wrapper {
display: none;
}
.first-install .hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url(/static/images/logos/lnbits.svg);
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.first-install .grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.first-install .hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
.wallet-list-card {
margin-top: 1px;
margin-right: 1rem;
}
.wallet-list-card:first-child {
margin-left: 1px;
}
@media (max-width: 1024px) {
.wallet-card {
background: none !important;
box-shadow: none !important;
border: none !important;
}
.mobile-simple .wallet-wrapper {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
}
.mobile-simple .wallet-card {
width: 90% !important;
}
}
+58 -3
View File
@@ -25,12 +25,14 @@ window.localisation.en = {
reconnect: 'Reconnect',
open_channel: 'Open Channel',
open: 'Open',
clear: 'Clear',
close_channel: 'Close Channel',
close: 'Close',
restart: 'Restart server',
image_library: 'Image Library',
save: 'Save',
save_tooltip: 'Save your changes',
must_save: 'You have unsaved changes',
credit_debit: 'Credit / Debit',
credit_hint: 'Press Enter to credit/debit wallet (negative values allowed)',
credit_label: '{denomination} to credit/debit',
@@ -52,9 +54,17 @@ window.localisation.en = {
stored_paylinks: 'Stored LNURL pay links',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallet_type: 'Wallet type',
shared_wallet: 'Shared Wallet',
share_wallet: 'Share Wallet',
update_permissions: 'Update Permissions',
shared_wallet_id: 'Shared Wallet ID',
shared_wallet_desc:
"You have been invited to have access to someone else's wallet.",
wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet',
reject_wallet: 'Reject wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
delete_wallet: 'Delete wallet',
@@ -72,6 +82,10 @@ window.localisation.en = {
donate: 'Donate',
view_github: 'View on GitHub',
voidwallet_active: 'VoidWallet is active! Payments disabled',
voidwallet_active_user:
'Funding source unavailable. Please contact your admin to configure.',
voidwallet_active_admin:
'Funding source unavailable. Click here to configure.',
service_fee_badge: 'Service fee: {amount} % per transaction',
service_fee_max_badge:
'Service fee: {amount} % per transaction (max {max} {denom})',
@@ -137,6 +151,8 @@ window.localisation.en = {
extensions: 'Extensions',
no_extensions: "You don't have any extensions installed :(",
created: 'Created',
created_at: 'Created At',
updated_at: 'Updated At',
search_extensions: 'Search extensions',
search_wallets: 'Search wallets',
extension_sources: 'Extension Sources',
@@ -147,6 +163,7 @@ window.localisation.en = {
repository: 'Repository',
confirm_continue: 'Are you sure you want to continue?',
manage_extension_details: 'Install/uninstall extension',
upload: 'Upload',
install: 'Install',
uninstall: 'Uninstall',
drop_db: 'Remove Data',
@@ -302,7 +319,7 @@ window.localisation.en = {
notification_balance_delta_changed: 'Balance Delta Changed',
notification_balance_delta_changed_desc:
'Notify when the diference between the node balance and the LNbits balance has changed even by 1 sat. This runs every minute.',
'Notify when the difference between the node balance and the LNbits balance has changed by more than the specified amount (in sats). Set to 0 to disable. This runs every minute.',
enable_watchdog: 'Enable Watchdog Switch',
enable_watchdog_desc:
@@ -395,6 +412,8 @@ window.localisation.en = {
first_name: 'First Name',
last_name: 'Last Name',
picture: 'Picture',
user_picture_desc:
'URL to an image to use as profile picture. You can upload it as an asset.',
verify_email: 'Verify email with',
account: 'Account',
update_account: 'Update Account',
@@ -419,6 +438,26 @@ window.localisation.en = {
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
language: 'Language',
assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)',
max_asset_size_mb_desc:
'The maximum allowed size for asset uploads in megabytes (can use decimal values).',
assets_allowed_mime_types: 'Allowed MIME Types',
assets_allowed_mime_types_desc:
'The MIME types that are allowed for asset uploads. No value means all uploads are allowed.',
thumbnail_width: 'Thumbnail Width',
thumbnail_width_desc: 'Width of the generated thumbnail in pixels.',
thumbnail_height: 'Thumbnail Height',
thumbnail_height_desc: 'Height of the generated thumbnail in pixels.',
thumbnail_format: 'Thumbnail Format',
thumbnail_format_desc:
'Image format of the generated thumbnail (PNG, JPEG, etc.).',
max_assets_per_user: 'Max Assets Per User',
max_assets_per_user_desc:
'The maximum number of assets a user can upload. Zero means upload forbidden.',
assets_no_limit_users: 'Users Without Asset Limits',
assets_no_limit_users_desc:
'These users can upload an unlimited number of assets (user id based).',
color_scheme: 'Color Scheme',
visible_wallet_count: 'Visible Wallet Count',
admin_settings: 'Admin Settings',
@@ -526,7 +565,9 @@ window.localisation.en = {
reserve_fee_in_percent: 'Reserve fee in percent',
payment_wait_time: 'Payment Wait Time (sec)',
payment_wait_time_desc:
'How long to wait when making a payment before marking it as pending. Set higher values for HODL invoices, Boltz, etc.',
'Wait time before marking an outgoing payment as pending. Default: 5s; raise for slow-settling invoices.',
payment_wait_time_tooltip:
'Controls how long LNbits waits for an outgoing payment attempt to confirm before marking it as pending. Higher values help when paying slow-settling invoices (e.g., HODL invoices, Boltz). The payment will be rechecked later and updated automatically or manually.',
server_management: 'Server Management',
base_url: 'Base URL',
base_url_label: 'Static/Base url for the server',
@@ -655,6 +696,7 @@ window.localisation.en = {
view_list: 'View wallets as list',
view_column: 'View wallets as rows',
filter_payments: 'Filter payments',
filter_labels: 'Filter labels',
filter_date: 'Filter by date',
websocket_example: 'Websocket example',
secret_key: 'Secret Key',
@@ -670,5 +712,18 @@ window.localisation.en = {
paid: 'Paid',
funding_source_retries: 'Max Retries',
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',
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',
sort: 'Sort',
sort_by: 'Sort by'
}
-696
View File
@@ -1,696 +0,0 @@
window.AdminPageLogic = {
mixins: [windowMixin],
data() {
return {
settings: {},
logs: [],
library_images: [],
serverlogEnabled: false,
lnbits_theme_options: [
'classic',
'bitcoin',
'flamingo',
'cyber',
'freedom',
'mint',
'autumn',
'monochrome',
'salvador'
],
reactionOptions: [
'none',
'confettiBothSides',
'confettiFireworks',
'confettiStars',
'confettiTop'
],
globalBorderOptions: [
'retro-border',
'hard-border',
'neon-border',
'no-border'
],
auditData: {},
statusData: {},
statusDataTable: {
columns: [
{
name: 'date',
align: 'left',
label: this.$t('date'),
field: 'date'
},
{
name: 'message',
align: 'left',
label: this.$t('memo'),
field: 'message'
}
]
},
formData: {
lnbits_exchange_rate_providers: []
},
chartReady: false,
formAddAdmin: '',
formAddUser: '',
formAddStripeUser: '',
hideInputToggle: true,
formAddExtensionsManifest: '',
nostrNotificationIdentifier: '',
emailNotificationAddress: '',
formAllowedIPs: '',
formCallbackUrlRule: '',
formBlockedIPs: '',
nostrAcceptedUrl: '',
formAddIncludePath: '',
formAddExcludePath: '',
formAddIncludeResponseCode: '',
isSuperUser: false,
wallet: {},
cancel: {},
colors: [
'primary',
'secondary',
'accent',
'positive',
'negative',
'info',
'warning',
'red',
'yellow',
'orange'
],
tab: 'funding',
needsRestart: false,
exchangesTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Exchange Name',
field: 'name',
sortable: true
},
{
name: 'api_url',
align: 'left',
label: 'URL',
field: 'api_url',
sortable: false
},
{
name: 'path',
align: 'left',
label: 'JSON Path',
field: 'path',
sortable: false
},
{
name: 'exclude_to',
align: 'left',
label: 'Exclude Currencies',
field: 'exclude_to',
sortable: false
},
{
name: 'ticker_conversion',
align: 'left',
label: 'Ticker Conversion',
field: 'ticker_conversion',
sortable: false
}
],
pagination: {
sortBy: 'name',
rowsPerPage: 100,
page: 1,
rowsNumber: 100
},
search: null,
hideEmpty: true
},
exchangeData: {
selectedProvider: null,
showTickerConversion: false,
convertFromTicker: null,
convertToTicker: null
}
}
},
async created() {
await this.getSettings()
await this.getAudit()
await this.getUploadedImages()
this.balance = +'{{ balance|safe }}'
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
if (hash) {
this.tab = hash
}
},
computed: {
lnbitsVersion() {
return LNBITS_VERSION
},
checkChanges() {
return !_.isEqual(this.settings, this.formData)
},
updateAvailable() {
return LNBITS_VERSION !== this.statusData.version
}
},
methods: {
addAdminUser() {
let addUser = this.formAddAdmin
let admin_users = this.formData.lnbits_admin_users
if (addUser && addUser.length && !admin_users.includes(addUser)) {
this.formData.lnbits_admin_users = [...admin_users, addUser]
this.formAddAdmin = ''
}
},
removeAdminUser(user) {
let admin_users = this.formData.lnbits_admin_users
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
},
addAllowedUser() {
let addUser = this.formAddUser
let allowed_users = this.formData.lnbits_allowed_users
if (addUser && addUser.length && !allowed_users.includes(addUser)) {
this.formData.lnbits_allowed_users = [...allowed_users, addUser]
this.formAddUser = ''
}
},
removeAllowedUser(user) {
let allowed_users = this.formData.lnbits_allowed_users
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
},
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
addUser.length &&
!this.formData.stripe_limits.allowed_users.includes(addUser)
) {
this.formData.stripe_limits.allowed_users = [
...this.formData.stripe_limits.allowed_users,
addUser
]
this.formAddStripeUser = ''
}
},
removeStripeAllowedUser(user) {
this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
},
addIncludePath() {
if (!this.formAddIncludePath) {
return
}
const paths = this.formData.lnbits_audit_include_paths
if (!paths.includes(this.formAddIncludePath)) {
this.formData.lnbits_audit_include_paths = [
...paths,
this.formAddIncludePath
]
}
this.formAddIncludePath = ''
},
removeIncludePath(path) {
this.formData.lnbits_audit_include_paths =
this.formData.lnbits_audit_include_paths.filter(p => p !== path)
},
addExcludePath() {
if (!this.formAddExcludePath) {
return
}
const paths = this.formData.lnbits_audit_exclude_paths
if (!paths.includes(this.formAddExcludePath)) {
this.formData.lnbits_audit_exclude_paths = [
...paths,
this.formAddExcludePath
]
}
this.formAddExcludePath = ''
},
removeExcludePath(path) {
this.formData.lnbits_audit_exclude_paths =
this.formData.lnbits_audit_exclude_paths.filter(p => p !== path)
},
addIncludeResponseCode() {
if (!this.formAddIncludeResponseCode) {
return
}
const codes = this.formData.lnbits_audit_http_response_codes
if (!codes.includes(this.formAddIncludeResponseCode)) {
this.formData.lnbits_audit_http_response_codes = [
...codes,
this.formAddIncludeResponseCode
]
}
this.formAddIncludeResponseCode = ''
},
removeIncludeResponseCode(code) {
this.formData.lnbits_audit_http_response_codes =
this.formData.lnbits_audit_http_response_codes.filter(c => c !== code)
},
addExtensionsManifest() {
const addManifest = this.formAddExtensionsManifest.trim()
const manifests = this.formData.lnbits_extensions_manifests
if (
addManifest &&
addManifest.length &&
!manifests.includes(addManifest)
) {
this.formData.lnbits_extensions_manifests = [...manifests, addManifest]
this.formAddExtensionsManifest = ''
}
},
removeExtensionsManifest(manifest) {
const manifests = this.formData.lnbits_extensions_manifests
this.formData.lnbits_extensions_manifests = manifests.filter(
m => m !== manifest
)
},
addNostrNotificationIdentifier() {
const identifer = this.nostrNotificationIdentifier.trim()
const identifiers = this.formData.lnbits_nostr_notifications_identifiers
if (identifer && identifer.length && !identifiers.includes(identifer)) {
this.formData.lnbits_nostr_notifications_identifiers = [
...identifiers,
identifer
]
this.nostrNotificationIdentifier = ''
}
},
removeNostrNotificationIdentifier(identifer) {
const identifiers = this.formData.lnbits_nostr_notifications_identifiers
this.formData.lnbits_nostr_notifications_identifiers = identifiers.filter(
m => m !== identifer
)
},
addEmailNotificationAddress() {
const email = this.emailNotificationAddress.trim()
const emails = this.formData.lnbits_email_notifications_to_emails
if (email && email.length && !emails.includes(email)) {
this.formData.lnbits_email_notifications_to_emails = [...emails, email]
this.emailNotificationAddress = ''
}
},
removeEmailNotificationAddress(email) {
const emails = this.formData.lnbits_email_notifications_to_emails
this.formData.lnbits_email_notifications_to_emails = emails.filter(
m => m !== email
)
},
hideInputsToggle() {
this.hideInputToggle = !this.hideInputToggle
},
async toggleServerLog() {
this.serverlogEnabled = !this.serverlogEnabled
if (this.serverlogEnabled) {
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
const localUrl =
wsProto +
document.domain +
':' +
location.port +
'/api/v1/ws/' +
digestHex
this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => {
this.logs.push(data.toString())
const scrollArea = this.$refs.logScroll
if (scrollArea) {
const scrollTarget = scrollArea.getScrollTarget()
const duration = 0
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
}
})
} else {
this.ws.close()
}
},
addAllowedIPs() {
const allowedIPs = this.formAllowedIPs.trim()
const allowed_ips = this.formData.lnbits_allowed_ips
if (
allowedIPs &&
allowedIPs.length &&
!allowed_ips.includes(allowedIPs)
) {
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
this.formAllowedIPs = ''
}
},
removeAllowedIPs(allowed_ip) {
const allowed_ips = this.formData.lnbits_allowed_ips
this.formData.lnbits_allowed_ips = allowed_ips.filter(
a => a !== allowed_ip
)
},
addBlockedIPs() {
const blockedIPs = this.formBlockedIPs.trim()
const blocked_ips = this.formData.lnbits_blocked_ips
if (
blockedIPs &&
blockedIPs.length &&
!blocked_ips.includes(blockedIPs)
) {
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
this.formBlockedIPs = ''
}
},
removeBlockedIPs(blocked_ip) {
const blocked_ips = this.formData.lnbits_blocked_ips
this.formData.lnbits_blocked_ips = blocked_ips.filter(
b => b !== blocked_ip
)
},
addCallbackUrlRule() {
const allowedCallback = this.formCallbackUrlRule.trim()
const allowedCallbacks = this.formData.lnbits_callback_url_rules
if (
allowedCallback &&
allowedCallback.length &&
!allowedCallbacks.includes(allowedCallback)
) {
this.formData.lnbits_callback_url_rules = [
...allowedCallbacks,
allowedCallback
]
this.formCallbackUrlRule = ''
}
},
removeCallbackUrlRule(allowedCallback) {
const allowedCallbacks = this.formData.lnbits_callback_url_rules
this.formData.lnbits_callback_url_rules = allowedCallbacks.filter(
a => a !== allowedCallback
)
},
addNostrUrl() {
const url = this.nostrAcceptedUrl.trim()
this.removeNostrUrl(url)
this.formData.nostr_absolute_request_urls.push(url)
this.nostrAcceptedUrl = ''
},
removeNostrUrl(url) {
this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url)
},
addExchangeProvider() {
this.formData.lnbits_exchange_rate_providers = [
{
name: '',
api_url: '',
path: '',
exclude_to: []
},
...this.formData.lnbits_exchange_rate_providers
]
},
removeExchangeProvider(provider) {
this.formData.lnbits_exchange_rate_providers =
this.formData.lnbits_exchange_rate_providers.filter(p => p !== provider)
},
removeExchangeTickerConversion(provider, ticker) {
provider.ticker_conversion = provider.ticker_conversion.filter(
t => t !== ticker
)
this.touchSettings()
},
addExchangeTickerConversion() {
if (!this.exchangeData.selectedProvider) {
return
}
this.exchangeData.selectedProvider.ticker_conversion.push(
`${this.exchangeData.convertFromTicker}:${this.exchangeData.convertToTicker}`
)
this.touchSettings()
this.exchangeData.showTickerConversion = false
},
showTickerConversionDialog(provider) {
this.exchangeData.convertFromTicker = null
this.exchangeData.convertToTicker = null
this.exchangeData.selectedProvider = provider
this.exchangeData.showTickerConversion = true
},
getDefaultSetting(fieldName) {
LNbits.api
.request(
'GET',
`/admin/api/v1/settings/default?field_name=${fieldName}`
)
.then(response => {
this.formData[fieldName] = response.data.default_value
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
restartServer() {
LNbits.api
.request('GET', '/admin/api/v1/restart/')
.then(response => {
this.$q.notify({
type: 'positive',
message: 'Success! Restarted Server',
icon: null
})
this.needsRestart = false
})
.catch(LNbits.utils.notifyApiError)
},
formatDate(date) {
return moment
.utc(date * 1000)
.local()
.fromNow()
},
sendTestEmail() {
LNbits.api
.request(
'GET',
'/admin/api/v1/testemail',
this.g.user.wallets[0].adminkey
)
.then(response => {
if (response.data.status === 'error') {
throw new Error(response.data.message)
}
this.$q.notify({
message: 'Test email sent!',
color: 'positive'
})
})
.catch(error => {
this.$q.notify({
message: error.message,
color: 'negative'
})
})
},
getAudit() {
LNbits.api
.request('GET', '/admin/api/v1/audit', this.g.user.wallets[0].adminkey)
.then(response => {
this.auditData = response.data
})
.catch(LNbits.utils.notifyApiError)
},
getExchangeRateHistory() {
LNbits.api
.request('GET', '/api/v1/rate/history', this.g.user.wallets[0].inkey)
.then(response => {
this.initExchangeChart(response.data)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
async getSettings() {
await LNbits.api
.request(
'GET',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey
)
.then(response => {
this.isSuperUser = response.data.is_super_user || false
this.settings = response.data
this.formData = {...this.settings}
})
.catch(LNbits.utils.notifyApiError)
},
updateSettings() {
const data = _.omit(this.formData, [
'is_super_user',
'lnbits_allowed_funding_sources',
'touch'
])
LNbits.api
.request(
'PUT',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
data
)
.then(response => {
this.needsRestart =
this.settings.lnbits_backend_wallet_class !==
this.formData.lnbits_backend_wallet_class
this.settings = this.formData
this.formData = _.clone(this.settings)
Quasar.Notify.create({
type: 'positive',
message: `Success! Settings changed! ${
this.needsRestart ? 'Restart required!' : ''
}`,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
deleteSettings() {
LNbits.utils
.confirmDialog('Are you sure you want to restore settings to default?')
.onOk(() => {
LNbits.api
.request('DELETE', '/admin/api/v1/settings')
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Success! Restored settings to defaults. Restarting...',
icon: null
})
this.$q.localStorage.clear()
})
.catch(LNbits.utils.notifyApiError)
})
},
onImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadImage(file)
}
},
uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
LNbits.api
.request(
'POST',
'/admin/api/v1/images',
this.g.user.wallets[0].adminkey,
formData,
{headers: {'Content-Type': 'multipart/form-data'}}
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Image uploaded!',
icon: null
})
this.getUploadedImages()
})
.catch(LNbits.utils.notifyApiError)
},
getUploadedImages() {
LNbits.api
.request('GET', '/admin/api/v1/images', this.g.user.wallets[0].inkey)
.then(response => {
this.library_images = response.data.map(image => ({
...image,
url: `${window.origin}/${image.directory}/${image.filename}`
}))
})
.catch(LNbits.utils.notifyApiError)
},
deleteImage(filename) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this image?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
`/admin/api/v1/images/${filename}`,
this.g.user.wallets[0].adminkey
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Image deleted!',
icon: null
})
this.getUploadedImages()
})
.catch(LNbits.utils.notifyApiError)
})
},
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
.then(response => {
response
const data = response.data
Quasar.Notify.create({
type: data.success ? 'positive' : 'warning',
message: data.message,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
downloadBackup() {
window.open('/admin/api/v1/backup', '_blank')
},
showExchangeProvidersTab(tabName) {
if (tabName === 'exchange_providers') {
this.getExchangeRateHistory()
}
},
touchSettings() {
this.formData.touch = null
},
initExchangeChart(data) {
const xValues = data.map(d =>
Quasar.date.formatDate(new Date(d.timestamp * 1000), 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const datasets = exchanges.map(exchange => ({
label: exchange.name,
data: data.map(d => d.rates[exchange.name]),
pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
this.$refs.exchangeRatesChart.getContext('2d'),
{
type: 'line',
options: {
plugins: {
legend: {
display: false
}
}
},
data: {
labels: xValues,
datasets
}
}
)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
window._lnbitsApi = {
request(method, url, apiKey, data, options = {}) {
return axios({
method: method,
url: url,
headers: {
'X-Api-Key': apiKey
},
data: data,
...options
})
},
getServerHealth() {
return this.request('get', '/api/v1/health')
},
async createInvoice(
wallet,
amount,
memo,
unit = 'sat',
lnurlWithdraw = null,
fiatProvider = null,
internalMemo = null,
payment_hash = null
) {
const data = {
out: false,
amount: amount,
memo: memo,
unit: unit,
lnurl_withdraw: lnurlWithdraw,
fiat_provider: fiatProvider,
payment_hash: payment_hash
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.inkey, data)
},
payInvoice(wallet, bolt11, internalMemo = null) {
const data = {
out: true,
bolt11: bolt11
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
},
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
})
},
settleInvoice(wallet, preimage) {
return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
preimage: preimage
})
},
createAccount(name) {
return this.request('post', '/api/v1/account', null, {
name: name
})
},
register(username, email, password, password_repeat) {
return axios({
method: 'POST',
url: '/api/v1/auth/register',
data: {
username,
email,
password,
password_repeat
}
})
},
reset(reset_key, password, password_repeat) {
return axios({
method: 'PUT',
url: '/api/v1/auth/reset',
data: {
reset_key,
password,
password_repeat
}
})
},
login(username, password) {
return axios({
method: 'POST',
url: '/api/v1/auth',
data: {username, password}
})
},
loginByProvider(provider, headers, data) {
return axios({
method: 'POST',
url: `/api/v1/auth/${provider}`,
headers: headers,
data
})
},
loginUsr(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/usr',
data: {usr}
})
},
logout() {
return axios({
method: 'POST',
url: '/api/v1/auth/logout'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
getWallet(wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey)
},
createWallet(wallet, name, walletType, ops = {}) {
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
name: name,
wallet_type: walletType,
...ops
}).then(res => {
window.location = '/wallet?wal=' + res.data.id
})
},
updateWallet(name, wallet) {
return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
name: name
})
},
resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => {
return res.data
}
)
},
deleteWallet(wallet) {
return this.request('delete', `/api/v1/wallet/${wallet.id}`).then(_ => {
let url = new URL(window.location.href)
url.searchParams.delete('wal')
window.location = url
})
},
getPayments(wallet, params) {
return this.request(
'get',
'/api/v1/payments/paginated?' + params,
wallet.inkey
)
},
getPayment(wallet, paymentHash) {
return this.request('get', '/api/v1/payments/' + paymentHash, wallet.inkey)
},
updateBalance(credit, wallet_id) {
return this.request('PUT', '/users/api/v1/balance', null, {
amount: credit,
id: wallet_id
})
},
getCurrencies() {
return this.request('GET', '/api/v1/currencies').then(response => {
return ['sats', ...response.data]
})
}
}
+22 -787
View File
@@ -1,200 +1,8 @@
window.LNbits = {
g: window.g,
api: {
request(method, url, apiKey, data, options = {}) {
return axios({
method: method,
url: url,
headers: {
'X-Api-Key': apiKey
},
data: data,
...options
})
},
getServerHealth() {
return this.request('get', '/api/v1/health')
},
async createInvoice(
wallet,
amount,
memo,
unit = 'sat',
lnurlWithdraw = null,
fiatProvider = null,
internalMemo = null,
payment_hash = null
) {
const data = {
out: false,
amount: amount,
memo: memo,
unit: unit,
lnurl_withdraw: lnurlWithdraw,
fiat_provider: fiatProvider,
payment_hash: payment_hash
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.inkey, data)
},
payInvoice(wallet, bolt11, internalMemo = null) {
const data = {
out: true,
bolt11: bolt11
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
},
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
})
},
settleInvoice(wallet, preimage) {
return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
preimage: preimage
})
},
createAccount(name) {
return this.request('post', '/api/v1/account', null, {
name: name
})
},
register(username, email, password, password_repeat) {
return axios({
method: 'POST',
url: '/api/v1/auth/register',
data: {
username,
email,
password,
password_repeat
}
})
},
reset(reset_key, password, password_repeat) {
return axios({
method: 'PUT',
url: '/api/v1/auth/reset',
data: {
reset_key,
password,
password_repeat
}
})
},
login(username, password) {
return axios({
method: 'POST',
url: '/api/v1/auth',
data: {username, password}
})
},
loginByProvider(provider, headers, data) {
return axios({
method: 'POST',
url: `/api/v1/auth/${provider}`,
headers: headers,
data
})
},
loginUsr(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/usr',
data: {usr}
})
},
logout() {
return axios({
method: 'POST',
url: '/api/v1/auth/logout'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
getWallet(wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey)
},
createWallet(wallet, name) {
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
name: name
}).then(res => {
window.location = '/wallet?wal=' + res.data.id
})
},
updateWallet(name, wallet) {
return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
name: name
})
},
resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => {
return res.data
}
)
},
deleteWallet(wallet) {
return this.request('delete', `/api/v1/wallet/${wallet.id}`).then(_ => {
let url = new URL(window.location.href)
url.searchParams.delete('wal')
window.location = url
})
},
getPayments(wallet, params) {
return this.request(
'get',
'/api/v1/payments/paginated?' + params,
wallet.inkey
)
},
getPayment(wallet, paymentHash) {
return this.request(
'get',
'/api/v1/payments/' + paymentHash,
wallet.inkey
)
},
updateBalance(credit, wallet_id) {
return this.request('PUT', '/users/api/v1/balance', null, {
amount: credit,
id: wallet_id
})
},
getCurrencies() {
return this.request('GET', '/api/v1/currencies').then(response => {
return ['sats', ...response.data]
})
}
},
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
}
},
utils: window._lnbitsUtils,
api: window._lnbitsApi,
map: {
extension(data) {
const obj = {...data}
obj.url = ['/', obj.code, '/'].join('')
return obj
},
user(data) {
const obj = {
id: data.id,
@@ -207,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(''),
@@ -227,605 +31,36 @@ window.LNbits = {
0,
data.wallets.length - data.extra.visible_wallet_count
)
obj.walletInvitesCount = data.extra.wallet_invite_requests?.length || 0
return obj
},
wallet(data) {
newWallet = {
id: data.id,
name: data.name,
walletType: data.wallet_type,
sharePermissions: data.share_permissions,
sharedWalletId: data.shared_wallet_id,
adminkey: data.adminkey,
inkey: data.inkey,
currency: data.currency,
extra: data.extra
extra: data.extra,
canReceivePayments: true,
canSendPayments: true
}
newWallet.msat = data.balance_msat
newWallet.sat = Math.floor(data.balance_msat / 1000)
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
newWallet.fsat = new Intl.NumberFormat(window.i18n.global.locale).format(
newWallet.sat
)
if (newWallet.walletType === 'lightning-shared') {
const perms = newWallet.sharePermissions
newWallet.canReceivePayments = perms.includes('receive-payments')
newWallet.canSendPayments = perms.includes('send-payments')
}
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
}
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.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
}
},
utils: {
confirmDialog(msg) {
return Quasar.Dialog.create({
message: msg,
ok: {
flat: true,
color: 'orange'
},
cancel: {
flat: true,
color: 'grey'
}
})
},
async digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray
.map(b => b.toString(16).padStart(2, '0'))
.join('')
return hashHex
},
formatDate(timestamp) {
return Quasar.date.formatDate(
new Date(timestamp * 1000),
window.dateFormat
)
},
formatDateString(isoDateString) {
return Quasar.date.formatDate(new Date(isoDateString), window.dateFormat)
},
formatCurrency(value, currency) {
return new Intl.NumberFormat(window.LOCALE, {
style: 'currency',
currency: currency || 'sat'
}).format(value)
},
formatSat(value) {
return new Intl.NumberFormat(window.LOCALE).format(value)
},
formatMsat(value) {
return this.formatSat(value / 1000)
},
notifyApiError(error) {
if (!error.response) {
return console.error(error)
}
const types = {
400: 'warning',
401: 'warning',
500: 'negative'
}
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message:
error.response.data.message || error.response.data.detail || null,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
.toUpperCase() || null,
icon: null
})
},
search(data, q, field, separator) {
try {
const queries = q.toLowerCase().split(separator || ' ')
return data.filter(obj => {
let matches = 0
_.each(queries, q => {
if (obj[field].indexOf(q) !== -1) matches++
})
return matches === queries.length
})
} catch (err) {
return data
}
},
prepareFilterQuery(tableConfig, props) {
tableConfig.filter = tableConfig.filter || {}
if (props) {
tableConfig.pagination = props.pagination
Object.assign(tableConfig.filter, props.filter)
}
const pagination = tableConfig.pagination
tableConfig.loading = true
const query = {
limit: pagination.rowsPerPage,
offset: (pagination.page - 1) * pagination.rowsPerPage,
sortby: pagination.sortBy ?? '',
direction: pagination.descending ? 'desc' : 'asc',
...tableConfig.filter
}
if (tableConfig.search) {
query.search = tableConfig.search
}
return new URLSearchParams(query)
},
exportCSV(columns, data, fileName) {
const wrapCsvValue = (val, formatFn) => {
let formatted = formatFn !== void 0 ? formatFn(val) : val
formatted =
formatted === void 0 || formatted === null ? '' : String(formatted)
formatted = formatted.split('"').join('""')
return `"${formatted}"`
}
const content = [
columns.map(col => {
return wrapCsvValue(col.label)
})
]
.concat(
data.map(row => {
return columns
.map(col => {
return wrapCsvValue(
typeof col.field === 'function'
? col.field(row)
: row[col.field === void 0 ? col.name : col.field],
col.format
)
})
.join(',')
})
)
.join('\r\n')
const status = Quasar.exportFile(
`${fileName || 'table-export'}.csv`,
content,
'text/csv'
)
if (status !== true) {
Quasar.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
}
},
convertMarkdown(text) {
const converter = new showdown.Converter()
converter.setFlavor('github')
converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text)
}
}
}
if (!window.g) {
window.g = Vue.reactive({
offline: !navigator.onLine,
visibleDrawer: false,
extensions: [],
user: null,
wallet: {},
fiatBalance: 0,
exchangeRate: 0,
fiatTracking: false,
wallets: [],
payments: [],
langs: [],
walletEventListeners: [],
updatePayments: false,
updatePaymentsHash: ''
})
}
window.windowMixin = {
i18n: window.i18n,
data() {
return {
g: window.g,
toggleSubs: true,
mobileSimple: true,
walletFlip: true,
showAddWalletDialog: {show: false},
isUserAuthorized: false,
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
allowedThemes: WINDOW_SETTINGS['LNBITS_THEME_OPTIONS'],
walletEventListeners: [],
darkChoice: this.$q.localStorage.has('lnbits.darkMode')
? this.$q.localStorage.getItem('lnbits.darkMode')
: true,
borderChoice: this.$q.localStorage.has('lnbits.border')
? this.$q.localStorage.getItem('lnbits.border')
: USE_DEFAULT_BORDER,
gradientChoice: this.$q.localStorage.has('lnbits.gradientBg')
? this.$q.localStorage.getItem('lnbits.gradientBg')
: USE_DEFAULT_GRADIENT,
themeChoice: this.$q.localStorage.has('lnbits.theme')
? this.$q.localStorage.getItem('lnbits.theme')
: USE_DEFAULT_THEME,
reactionChoice: this.$q.localStorage.has('lnbits.reactions')
? this.$q.localStorage.getItem('lnbits.reactions')
: USE_DEFAULT_REACTION,
bgimageChoice: this.$q.localStorage.has('lnbits.backgroundImage')
? this.$q.localStorage.getItem('lnbits.backgroundImage')
: USE_DEFAULT_BGIMAGE,
...WINDOW_SETTINGS
}
},
methods: {
flipWallets(smallScreen) {
this.walletFlip = !this.walletFlip
if (this.walletFlip && smallScreen) {
this.g.visibleDrawer = false
}
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
},
goToWallets() {
this.$router.push({
path: '/wallets'
})
},
submitAddWallet() {
if (
this.showAddWalletDialog.name &&
this.showAddWalletDialog.name.length > 0
) {
LNbits.api.createWallet(
this.g.user.wallets[0],
this.showAddWalletDialog.name
)
this.showAddWalletDialog = {show: false}
} else {
this.$q.notify({
message: 'Please enter a name for the wallet',
color: 'negative'
})
}
},
simpleMobile() {
this.$q.localStorage.set('lnbits.mobileSimple', !this.mobileSimple)
this.refreshRoute()
},
paymentEvents() {
this.g.walletEventListeners = this.g.walletEventListeners || []
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
)
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
})
}
})
},
selectWallet(wallet) {
Object.assign(this.g.wallet, wallet)
// this.wallet = this.g.wallet
this.g.updatePayments = !this.g.updatePayments
this.balance = parseInt(wallet.balance_msat / 1000)
const currentPath = this.$route.path
if (currentPath !== '/wallet') {
this.$router.push({
path: '/wallet',
query: {wal: this.g.wallet.id}
})
} else {
this.$router.replace({
path: '/wallet',
query: {wal: this.g.wallet.id}
})
}
},
formatBalance(amount) {
if (LNBITS_DENOMINATION != 'sats') {
return LNbits.utils.formatCurrency(amount / 100, LNBITS_DENOMINATION)
} else {
return LNbits.utils.formatSat(amount) + ' sats'
}
},
changeTheme(newValue) {
document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue)
this.themeChoice = newValue
},
applyGradient() {
if (this.gradientChoice) {
document.body.classList.add('gradient-bg')
this.$q.localStorage.set('lnbits.gradientBg', true)
// Ensure dark mode is enabled when gradient background is applied
if (!this.$q.dark.isActive) {
this.toggleDarkMode()
}
} else {
document.body.classList.remove('gradient-bg')
this.$q.localStorage.set('lnbits.gradientBg', false)
}
},
applyBackgroundImage() {
if (this.bgimageChoice == 'null') this.bgimageChoice = ''
if (this.bgimageChoice == '') {
document.body.classList.remove('bg-image')
} else {
document.body.classList.add('bg-image')
document.body.style.setProperty(
'--background',
`url(${this.bgimageChoice})`
)
}
this.$q.localStorage.set('lnbits.backgroundImage', this.bgimageChoice)
},
applyBorder() {
// Remove any existing border classes
document.body.classList.forEach(cls => {
if (cls.endsWith('-border')) {
document.body.classList.remove(cls)
}
})
this.$q.localStorage.setItem('lnbits.border', this.borderChoice)
document.body.classList.add(this.borderChoice)
},
toggleDarkMode() {
this.$q.dark.toggle()
this.darkChoice = this.$q.dark.isActive
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive) {
this.gradientChoice = false
this.applyGradient()
}
},
copyText(text, message, position) {
Quasar.copyToClipboard(text).then(() => {
Quasar.Notify.create({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
},
async checkUsrInUrl() {
try {
const params = new URLSearchParams(window.location.search)
const usr = params.get('usr')
if (!usr) {
return
}
if (!this.isUserAuthorized) {
await LNbits.api.loginUsr(usr)
}
params.delete('usr')
const cleanQueryPrams = params.size ? `?${params.toString()}` : ''
window.history.replaceState(
{},
document.title,
window.location.pathname + cleanQueryPrams
)
} finally {
this.isUserAuthorized = !!this.$q.cookies.get(
'is_lnbits_user_authorized'
)
}
},
async logout() {
LNbits.utils
.confirmDialog(
'Do you really want to logout?' +
' Please visit "My Account" page to check your credentials!'
)
.onOk(async () => {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
})
},
themeParams() {
const url = new URL(window.location.href)
const params = new URLSearchParams(window.location.search)
const fields = ['theme', 'dark', 'gradient']
const toBoolean = value =>
value.trim().toLowerCase() === 'true' || value === '1'
// Check if any of the relevant parameters ('theme', 'dark', 'gradient') are present in the URL.
if (fields.some(param => params.has(param))) {
const theme = params.get('theme')
const darkMode = params.get('dark')
const gradient = params.get('gradient')
const border = params.get('border')
if (theme && this.allowedThemes.includes(theme.trim().toLowerCase())) {
const normalizedTheme = theme.trim().toLowerCase()
document.body.setAttribute('data-theme', normalizedTheme)
this.$q.localStorage.set('lnbits.theme', normalizedTheme)
}
if (darkMode) {
const isDark = toBoolean(darkMode)
this.$q.localStorage.set('lnbits.darkMode', isDark)
if (!isDark) {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
}
if (gradient) {
const isGradient = toBoolean(gradient)
this.$q.localStorage.set('lnbits.gradientBg', isGradient)
if (isGradient) {
this.$q.localStorage.set('lnbits.darkMode', true)
}
}
if (border) {
this.$q.localStorage.set('lnbits.border', border)
}
// Remove processed parameters
fields.forEach(param => params.delete(param))
window.history.replaceState(null, null, url.pathname)
}
},
refreshRoute() {
const path = window.location.pathname
console.log(path)
this.$router.push('/temp').then(() => {
this.$router.replace({path})
})
}
},
async created() {
this.$q.dark.set(
this.$q.localStorage.has('lnbits.darkMode')
? this.$q.localStorage.getItem('lnbits.darkMode')
: true
)
Chart.defaults.color = this.$q.dark.isActive ? '#fff' : '#000'
this.changeTheme(this.themeChoice)
this.applyBorder()
if (this.$q.dark.isActive) {
this.applyGradient()
}
this.applyBackgroundImage()
let locale = this.$q.localStorage.getItem('lnbits.lang')
if (locale) {
window.LOCALE = locale
window.i18n.global.locale = locale
}
this.g.langs = window.langs ?? []
addEventListener('offline', event => {
this.g.offline = true
})
addEventListener('online', event => {
this.g.offline = false
})
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)
}
await this.checkUsrInUrl()
this.themeParams()
this.walletFlip = this.$q.localStorage.getItem('lnbits.walletFlip')
if (
this.$q.screen.gt.sm ||
this.$q.localStorage.getItem('lnbits.mobileSimple') == false
) {
this.mobileSimple = false
}
},
mounted() {
if (this.g.user) {
this.paymentEvents()
}
}
}
window.decryptLnurlPayAES = (success_action, preimage) => {
let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
)
return crypto.subtle
.importKey('raw', keyb, {name: 'AES-CBC', length: 256}, false, ['decrypt'])
.then(key => {
let ivb = Uint8Array.from(window.atob(success_action.iv), c =>
c.charCodeAt(0)
)
let ciphertextb = Uint8Array.from(
window.atob(success_action.ciphertext),
c => c.charCodeAt(0)
)
return crypto.subtle.decrypt({name: 'AES-CBC', iv: ivb}, key, ciphertextb)
})
.then(valueb => {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
}
+275 -80
View File
@@ -21,89 +21,26 @@ window.app.component('lnbits-fsat', {
}
})
window.app.component('lnbits-wallet-list', {
mixins: [window.windowMixin],
template: '#lnbits-wallet-list',
props: ['balance'],
data() {
return {
activeWallet: null,
balance: 0,
showForm: false,
walletName: '',
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
methods: {
createWallet() {
LNbits.api.createWallet(this.g.user.wallets[0], this.walletName)
}
},
created() {
document.addEventListener('updateWalletBalance', this.updateWalletBalance)
}
})
window.app.component('lnbits-extension-list', {
mixins: [window.windowMixin],
template: '#lnbits-extension-list',
data() {
return {
extensions: [],
searchTerm: ''
}
},
watch: {
'g.user.extensions': {
handler(newExtensions) {
this.loadExtensions()
},
deep: true
}
},
computed: {
userExtensions() {
return this.updateUserExtensions(this.searchTerm)
}
},
methods: {
async loadExtensions() {
try {
const {data} = await LNbits.api.request('GET', '/api/v1/extension')
this.extensions = data
.map(extension => LNbits.map.extension(extension))
.sort((a, b) => a.name.localeCompare(b.name))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateUserExtensions(filterBy) {
const path = window.location.pathname
const userExtensions = this.g.user.extensions
return this.extensions
.filter(o => userExtensions.includes(o.code))
.filter(o => {
if (!filterBy) return true
return `${o.code} ${o.name} ${o.short_description} ${o.url}`
.toLocaleLowerCase()
.includes(filterBy.toLocaleLowerCase())
})
.map(obj => {
obj.isActive = path.startsWith(obj.url)
return obj
})
}
},
async created() {
await this.loadExtensions()
}
})
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
computed: {
showAdmin() {
return this.LNBITS_ADMIN_UI
},
showUsers() {
return this.LNBITS_ADMIN_UI
},
showNode() {
return this.LNBITS_NODE_UI
},
showAudit() {
return this.LNBITS_AUDIT_ENABLED
},
showExtensions() {
return this.LNBITS_EXTENSIONS_DEACTIVATE_ALL === false
}
},
methods: {
isActive(path) {
return window.location.pathname === path
@@ -656,3 +593,261 @@ window.app.component('separator-text', {
}
}
})
window.app.component('lnbits-node-ranks', {
props: ['ranks'],
data() {
return {
stats: [
{label: 'Capacity', key: 'capacity'},
{label: 'Channels', key: 'channelcount'},
{label: 'Age', key: 'age'},
{label: 'Growth', key: 'growth'},
{label: 'Availability', key: 'availability'}
]
}
},
template: `
<q-card class='q-my-none'>
<div class='column q-ma-md'>
<h5 class='text-subtitle1 text-bold q-my-none'>1ml Node Rank</h5>
<div v-for='stat in stats' class='q-gutter-sm'>
<div class='row items-center'>
<div class='col-9'>{{ stat.label }}</div>
<div class='col-3 text-subtitle1 text-bold'>
{{ (ranks && ranks[stat.key]) ?? '-' }}
</div>
</div>
</div>
</div>
</q-card>
`
})
window.app.component('lnbits-channel-stats', {
props: ['stats'],
data() {
return {
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
]
}
},
template: `
<q-card>
<div class='column q-ma-md'>
<h5 class='text-subtitle1 text-bold q-my-none'>Channels</h5>
<div v-for='state in states' class='q-gutter-sm'>
<div class='row'>
<div class='col-9'>
<q-badge rounded size='md' :color='state.color'>{{ state.label }}</q-badge>
</div>
<div class='col-3 text-subtitle1 text-bold'>
{{ (stats?.counts && stats.counts[state.value]) ?? "-" }}
</div>
</div>
</div>
</div>
</q-card>
`
})
window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'],
computed: {
value() {
return (
this.amount ??
(this.btc
? LNbits.utils.formatSat(this.btc)
: LNbits.utils.formatMsat(this.msat))
)
}
},
template: `
<q-card>
<q-card-section>
<div class='text-overline text-primary'>
{{ title }}
</div>
<div>
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
<span class='text-h5' v-if='msat != undefined'>sats</span>
<span class='text-h5' v-if='btc != undefined'>BTC</span>
</div>
</q-card-section>
</q-card>
`
})
window.app.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [window.windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
<div class="text-h6">
<div style="text-align: center">
<vue-qrcode
:value="info.addresses[0]"
:options="{width: 250}"
v-if='info.addresses[0]'
class="rounded-borders"
></vue-qrcode>
<div v-else class='text-subtitle1'>
No addresses available
</div>
</div>
</div>
</q-card-section>
<q-card-actions vertical>
<q-btn
dense
unelevated
size="md"
@click="copyText(info.id)"
>Public Key<q-tooltip> Click to copy </q-tooltip>
</q-btn>
</q-card-actions>
</q-card>
`
})
window.app.component('lnbits-channel-balance', {
props: ['balance', 'color'],
methods: {
formatMsat(msat) {
return LNbits.utils.formatMsat(msat)
}
},
template: `
<div>
<div class="row items-center justify-between">
<span class="text-weight-thin">
Local: {{ formatMsat(balance.local_msat) }}
sats
</span>
<span class="text-weight-thin">
Remote: {{ formatMsat(balance.remote_msat) }}
sats
</span>
</div>
<q-linear-progress
rounded
size="25px"
:value="balance.local_msat / balance.total_msat"
:color="color"
:style="\`color: #\${this.color}\`"
>
<div class="absolute-full flex flex-center">
<q-badge
color="white"
text-color="accent"
:label="formatMsat(balance.total_msat) + ' sats'"
>
{{ balance.alias }}
</q-badge>
</div>
</q-linear-progress>
</div>
`
})
window.app.component('lnbits-date', {
props: ['ts'],
computed: {
date() {
return LNbits.utils.formatDate(this.ts)
},
dateFrom() {
return moment.utc(this.date).local().fromNow()
}
},
template: `
<div>
<q-tooltip>{{ this.date }}</q-tooltip>
{{ this.dateFrom }}
</div>
`
})
window.app.component('lnbits-node-info', {
props: ['info'],
data() {
return {
showDialog: false
}
},
mixins: [window.windowMixin],
methods: {
shortenNodeId(nodeId) {
return nodeId
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
: '...'
}
},
template: `
<div class='row items-baseline q-gutter-x-sm'>
<div class='text-h4 text-bold'>{{ this.info.alias }}</div>
<div class='row items-center q-gutter-sm'>
<div class='text-subtitle1 text-light'>{{ this.info.backend_name }}</div>
<q-badge
:style='\`background-color: #\${this.info.color}\`'
class='text-bold'
>
#{{ this.info.color }}
</q-badge>
<div class='text-bold'>{{ shortenNodeId(this.info.id) }}</div>
<q-btn
size='xs'
flat
dense
icon='content_paste'
@click='copyText(info.id)'
></q-btn>
<q-btn
size='xs'
flat
dense
icon='qr_code'
@click='showDialog = true'
></q-btn>
</div>
<q-dialog v-model="showDialog">
<lnbits-node-qrcode :info='info'></lnbits-node-qrcode>
</q-dialog>
</div>
`
})
window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'],
computed: {
value() {
return (
this.amount ??
(this.btc
? LNbits.utils.formatSat(this.btc)
: LNbits.utils.formatMsat(this.msat))
)
}
},
template: `
<q-card>
<q-card-section>
<div class='text-overline text-primary'>
{{ title }}
</div>
<div>
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
<span class='text-h5' v-if='msat != undefined'>sats</span>
<span class='text-h5' v-if='btc != undefined'>BTC</span>
</div>
</q-card-section>
</q-card>
`
})
@@ -0,0 +1,42 @@
window.app.component('lnbits-admin-assets-config', {
props: ['form-data'],
template: '#lnbits-admin-assets-config',
mixins: [window.windowMixin],
data() {
return {
newAllowedAssetMimeType: '',
newNoLimitUser: ''
}
},
async created() {},
methods: {
addAllowedAssetMimeType() {
if (this.newAllowedAssetMimeType) {
this.removeAllowedAssetMimeType(this.newAllowedAssetMimeType)
this.formData.lnbits_assets_allowed_mime_types.push(
this.newAllowedAssetMimeType
)
this.newAllowedAssetMimeType = ''
}
},
removeAllowedAssetMimeType(type) {
const index = this.formData.lnbits_assets_allowed_mime_types.indexOf(type)
if (index !== -1) {
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
}
},
addNewNoLimitUser() {
if (this.newNoLimitUser) {
this.removeNoLimitUser(this.newNoLimitUser)
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
this.newNoLimitUser = ''
}
},
removeNoLimitUser(user) {
if (user) {
this.formData.lnbits_assets_no_limit_users =
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
}
}
}
})
@@ -0,0 +1,66 @@
window.app.component('lnbits-admin-audit', {
props: ['form-data'],
template: '#lnbits-admin-audit',
mixins: [window.windowMixin],
data() {
return {
formAddIncludePath: '',
formAddExcludePath: '',
formAddIncludeResponseCode: ''
}
},
methods: {
addIncludePath() {
if (this.formAddIncludePath === '') {
return
}
const paths = this.formData.lnbits_audit_include_paths
if (!paths.includes(this.formAddIncludePath)) {
this.formData.lnbits_audit_include_paths = [
...paths,
this.formAddIncludePath
]
}
this.formAddIncludePath = ''
},
removeIncludePath(path) {
this.formData.lnbits_audit_include_paths =
this.formData.lnbits_audit_include_paths.filter(p => p !== path)
},
addExcludePath() {
if (this.formAddExcludePath === '') {
return
}
const paths = this.formData.lnbits_audit_exclude_paths
if (!paths.includes(this.formAddExcludePath)) {
this.formData.lnbits_audit_exclude_paths = [
...paths,
this.formAddExcludePath
]
}
this.formAddExcludePath = ''
},
removeExcludePath(path) {
this.formData.lnbits_audit_exclude_paths =
this.formData.lnbits_audit_exclude_paths.filter(p => p !== path)
},
addIncludeResponseCode() {
if (this.formAddIncludeResponseCode === '') {
return
}
const codes = this.formData.lnbits_audit_http_response_codes
if (!codes.includes(this.formAddIncludeResponseCode)) {
this.formData.lnbits_audit_http_response_codes = [
...codes,
this.formAddIncludeResponseCode
]
}
this.formAddIncludeResponseCode = ''
},
removeIncludeResponseCode(code) {
this.formData.lnbits_audit_http_response_codes =
this.formData.lnbits_audit_http_response_codes.filter(c => c !== code)
}
}
})
@@ -0,0 +1,159 @@
window.app.component('lnbits-admin-exchange-providers', {
props: ['form-data'],
template: '#lnbits-admin-exchange-providers',
mixins: [window.windowMixin],
data() {
return {
exchangeData: {
selectedProvider: null,
showTickerConversion: false,
convertFromTicker: null,
convertToTicker: null
},
exchangesTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Exchange Name',
field: 'name',
sortable: true
},
{
name: 'api_url',
align: 'left',
label: 'URL',
field: 'api_url',
sortable: false
},
{
name: 'path',
align: 'left',
label: 'JSON Path',
field: 'path',
sortable: false
},
{
name: 'exclude_to',
align: 'left',
label: 'Exclude Currencies',
field: 'exclude_to',
sortable: false
},
{
name: 'ticker_conversion',
align: 'left',
label: 'Ticker Conversion',
field: 'ticker_conversion',
sortable: false
}
],
pagination: {
sortBy: 'name',
rowsPerPage: 100,
page: 1,
rowsNumber: 100
},
search: null,
hideEmpty: true
}
}
},
mounted() {
this.getExchangeRateHistory()
},
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: {
getExchangeRateHistory() {
LNbits.api
.request('GET', '/api/v1/rate/history', this.g.user.wallets[0].inkey)
.then(response => {
this.initExchangeChart(response.data)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
showExchangeProvidersTab(tabName) {
if (tabName === 'exchange_providers') {
this.getExchangeRateHistory()
}
},
addExchangeProvider() {
this.formData.lnbits_exchange_rate_providers = [
{
name: '',
api_url: '',
path: '',
exclude_to: []
},
...this.formData.lnbits_exchange_rate_providers
]
},
removeExchangeProvider(provider) {
this.formData.lnbits_exchange_rate_providers =
this.formData.lnbits_exchange_rate_providers.filter(p => p !== provider)
},
removeExchangeTickerConversion(provider, ticker) {
provider.ticker_conversion = provider.ticker_conversion.filter(
t => t !== ticker
)
this.formData.touch = null
},
addExchangeTickerConversion() {
if (!this.exchangeData.selectedProvider) {
return
}
this.exchangeData.selectedProvider.ticker_conversion.push(
`${this.exchangeData.convertFromTicker}:${this.exchangeData.convertToTicker}`
)
this.formData.touch = null
this.exchangeData.showTickerConversion = false
},
showTickerConversionDialog(provider) {
this.exchangeData.convertFromTicker = null
this.exchangeData.convertToTicker = null
this.exchangeData.selectedProvider = provider
this.exchangeData.showTickerConversion = true
},
initExchangeChart(data) {
const xValues = data.map(d =>
Quasar.date.formatDate(new Date(d.timestamp * 1000), 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const datasets = exchanges.map(exchange => ({
label: exchange.name,
data: data.map(d => d.rates[exchange.name]),
pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
this.$refs.exchangeRatesChart.getContext('2d'),
{
type: 'line',
options: {
plugins: {
legend: {
display: false
}
}
},
data: {
labels: xValues,
datasets
}
}
)
}
}
})
@@ -0,0 +1,30 @@
window.app.component('lnbits-admin-extensions', {
props: ['form-data'],
template: '#lnbits-admin-extensions',
mixins: [window.windowMixin],
data() {
return {
formAddExtensionsManifest: ''
}
},
methods: {
addExtensionsManifest() {
const addManifest = this.formAddExtensionsManifest.trim()
const manifests = this.formData.lnbits_extensions_manifests
if (
addManifest &&
addManifest.length &&
!manifests.includes(addManifest)
) {
this.formData.lnbits_extensions_manifests = [...manifests, addManifest]
this.formAddExtensionsManifest = ''
}
},
removeExtensionsManifest(manifest) {
const manifests = this.formData.lnbits_extensions_manifests
this.formData.lnbits_extensions_manifests = manifests.filter(
m => m !== manifest
)
}
}
})
@@ -0,0 +1,44 @@
window.app.component('lnbits-admin-fiat-providers', {
props: ['form-data'],
template: '#lnbits-admin-fiat-providers',
mixins: [window.windowMixin],
data() {
return {
formAddStripeUser: '',
hideInputToggle: true
}
},
methods: {
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
addUser.length &&
!this.formData.stripe_limits.allowed_users.includes(addUser)
) {
this.formData.stripe_limits.allowed_users = [
...this.formData.stripe_limits.allowed_users,
addUser
]
this.formAddStripeUser = ''
}
},
removeStripeAllowedUser(user) {
this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
.then(response => {
response
const data = response.data
Quasar.Notify.create({
type: data.success ? 'positive' : 'warning',
message: data.message,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -1,5 +1,5 @@
window.app.component('lnbits-funding-sources', {
template: '#lnbits-funding-sources',
window.app.component('lnbits-admin-funding-sources', {
template: '#lnbits-admin-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
@@ -100,7 +100,9 @@ window.app.component('lnbits-funding-sources', {
lnd_grpc_endpoint: 'Endpoint',
lnd_grpc_cert: 'Certificate',
lnd_grpc_port: 'Port',
lnd_grpc_admin_macaroon: 'Admin Macaroon',
lnd_grpc_macaroon: 'GRPC Macaroon',
lnd_grpc_invoice_macaroon: 'GRPC Invoice Macaroon',
lnd_grpc_admin_macaroon: 'GRPC Admin Macaroon',
lnd_grpc_macaroon_encrypted: 'Encrypted Macaroon'
}
],
@@ -0,0 +1,24 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
mixins: [window.windowMixin],
data() {
return {
auditData: []
}
},
created() {
this.getAudit()
},
methods: {
getAudit() {
LNbits.api
// TODO: should not use admin key here
.request('GET', '/admin/api/v1/audit', this.g.user.wallets[0].adminkey)
.then(response => {
this.auditData = response.data
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -0,0 +1,67 @@
window.app.component('lnbits-admin-notifications', {
props: ['form-data'],
template: '#lnbits-admin-notifications',
mixins: [window.windowMixin],
data() {
return {
nostrNotificationIdentifier: '',
emailNotificationAddress: ''
}
},
methods: {
sendTestEmail() {
LNbits.api
.request(
'GET',
'/admin/api/v1/testemail',
this.g.user.wallets[0].adminkey
)
.then(response => {
if (response.data.status === 'error') {
throw new Error(response.data.message)
}
this.$q.notify({
message: 'Test email sent!',
color: 'positive'
})
})
.catch(error => {
this.$q.notify({
message: error.message,
color: 'negative'
})
})
},
addNostrNotificationIdentifier() {
const identifer = this.nostrNotificationIdentifier.trim()
const identifiers = this.formData.lnbits_nostr_notifications_identifiers
if (identifer && identifer.length && !identifiers.includes(identifer)) {
this.formData.lnbits_nostr_notifications_identifiers = [
...identifiers,
identifer
]
this.nostrNotificationIdentifier = ''
}
},
removeNostrNotificationIdentifier(identifer) {
const identifiers = this.formData.lnbits_nostr_notifications_identifiers
this.formData.lnbits_nostr_notifications_identifiers = identifiers.filter(
m => m !== identifer
)
},
addEmailNotificationAddress() {
const email = this.emailNotificationAddress.trim()
const emails = this.formData.lnbits_email_notifications_to_emails
if (email && email.length && !emails.includes(email)) {
this.formData.lnbits_email_notifications_to_emails = [...emails, email]
this.emailNotificationAddress = ''
}
},
removeEmailNotificationAddress(email) {
const emails = this.formData.lnbits_email_notifications_to_emails
this.formData.lnbits_email_notifications_to_emails = emails.filter(
m => m !== email
)
}
}
})
@@ -0,0 +1,111 @@
window.app.component('lnbits-admin-security', {
props: ['form-data'],
template: '#lnbits-admin-security',
mixins: [window.windowMixin],
data() {
return {
logs: [],
formBlockedIPs: '',
serverlogEnabled: false,
nostrAcceptedUrl: '',
formAllowedIPs: '',
formCallbackUrlRule: ''
}
},
created() {},
methods: {
addAllowedIPs() {
const allowedIPs = this.formAllowedIPs.trim()
const allowed_ips = this.formData.lnbits_allowed_ips
if (
allowedIPs &&
allowedIPs.length &&
!allowed_ips.includes(allowedIPs)
) {
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
this.formAllowedIPs = ''
}
},
removeAllowedIPs(allowed_ip) {
const allowed_ips = this.formData.lnbits_allowed_ips
this.formData.lnbits_allowed_ips = allowed_ips.filter(
a => a !== allowed_ip
)
},
addBlockedIPs() {
const blockedIPs = this.formBlockedIPs.trim()
const blocked_ips = this.formData.lnbits_blocked_ips
if (
blockedIPs &&
blockedIPs.length &&
!blocked_ips.includes(blockedIPs)
) {
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
this.formBlockedIPs = ''
}
},
removeBlockedIPs(blocked_ip) {
const blocked_ips = this.formData.lnbits_blocked_ips
this.formData.lnbits_blocked_ips = blocked_ips.filter(
b => b !== blocked_ip
)
},
addCallbackUrlRule() {
const allowedCallback = this.formCallbackUrlRule.trim()
const allowedCallbacks = this.formData.lnbits_callback_url_rules
if (
allowedCallback &&
allowedCallback.length &&
!allowedCallbacks.includes(allowedCallback)
) {
this.formData.lnbits_callback_url_rules = [
...allowedCallbacks,
allowedCallback
]
this.formCallbackUrlRule = ''
}
},
removeCallbackUrlRule(allowedCallback) {
const allowedCallbacks = this.formData.lnbits_callback_url_rules
this.formData.lnbits_callback_url_rules = allowedCallbacks.filter(
a => a !== allowedCallback
)
},
addNostrUrl() {
const url = this.nostrAcceptedUrl.trim()
this.removeNostrUrl(url)
this.formData.nostr_absolute_request_urls.push(url)
this.nostrAcceptedUrl = ''
},
removeNostrUrl(url) {
this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url)
},
async toggleServerLog() {
this.serverlogEnabled = !this.serverlogEnabled
if (this.serverlogEnabled) {
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
const localUrl =
wsProto +
document.domain +
':' +
location.port +
'/api/v1/ws/' +
digestHex
this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => {
this.logs.push(data.toString())
const scrollArea = this.$refs.logScroll
if (scrollArea) {
const scrollTarget = scrollArea.getScrollTarget()
const duration = 0
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
}
})
} else {
this.ws.close()
}
}
}
})
@@ -0,0 +1,5 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server',
mixins: [window.windowMixin]
})
@@ -0,0 +1,46 @@
window.app.component('lnbits-admin-site-customisation', {
props: ['form-data'],
template: '#lnbits-admin-site-customisation',
mixins: [window.windowMixin],
data() {
return {
lnbits_theme_options: [
'classic',
'bitcoin',
'flamingo',
'cyber',
'freedom',
'mint',
'autumn',
'monochrome',
'salvador'
],
colors: [
'primary',
'secondary',
'accent',
'positive',
'negative',
'info',
'warning',
'red',
'yellow',
'orange'
],
reactionOptions: [
'none',
'confettiBothSides',
'confettiFireworks',
'confettiStars',
'confettiTop'
],
globalBorderOptions: [
'retro-border',
'hard-border',
'neon-border',
'no-border'
]
}
},
methods: {}
})
@@ -0,0 +1,37 @@
window.app.component('lnbits-admin-users', {
props: ['form-data'],
template: '#lnbits-admin-users',
mixins: [window.windowMixin],
data() {
return {
formAddUser: '',
formAddAdmin: ''
}
},
methods: {
addAllowedUser() {
let addUser = this.formAddUser
let allowed_users = this.formData.lnbits_allowed_users
if (addUser && addUser.length && !allowed_users.includes(addUser)) {
this.formData.lnbits_allowed_users = [...allowed_users, addUser]
this.formAddUser = ''
}
},
removeAllowedUser(user) {
let allowed_users = this.formData.lnbits_allowed_users
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
},
addAdminUser() {
let addUser = this.formAddAdmin
let admin_users = this.formData.lnbits_admin_users
if (addUser && addUser.length && !admin_users.includes(addUser)) {
this.formData.lnbits_admin_users = [...admin_users, addUser]
this.formAddAdmin = ''
}
},
removeAdminUser(user) {
let admin_users = this.formData.lnbits_admin_users
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
}
}
})
@@ -0,0 +1,9 @@
window.app.component('lnbits-disclaimer', {
template: '#lnbits-disclaimer',
mixins: [window.windowMixin],
computed: {
showDisclaimer() {
return !g.disclaimerShown && g.isUserAuthorized
}
}
})
@@ -0,0 +1,4 @@
window.app.component('lnbits-drawer', {
template: '#lnbits-drawer',
mixins: [window.windowMixin]
})
@@ -0,0 +1,15 @@
window.app.component('lnbits-footer', {
template: '#lnbits-footer',
mixins: [window.windowMixin],
computed: {
version() {
return this.LNBITS_VERSION || 'unknown version'
},
title() {
return `${this.SITE_TITLE}, ${this.SITE_TAGLINE}`
},
showFooter() {
return this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
}
}
})
@@ -0,0 +1,4 @@
window.app.component('lnbits-header-wallets', {
template: '#lnbits-header-wallets',
mixins: [window.windowMixin]
})
@@ -0,0 +1,57 @@
window.app.component('lnbits-header', {
template: '#lnbits-header',
mixins: [window.windowMixin],
computed: {
denomination() {
return this.LNBITS_DENOMINATION || 'sat'
},
hasServiceFeeMax() {
return (
this.g.user &&
this.LNBITS_SERVICE_FEE_MAX &&
this.LNBITS_SERVICE_FEE_MAX > 0
)
},
serviceFeeMax() {
return this.LNBITS_SERVICE_FEE_MAX || 0
},
hasServiceFee() {
return (
this.g.user && this.LNBITS_SERVICE_FEE && this.LNBITS_SERVICE_FEE > 0
)
},
serviceFee() {
return this.LNBITS_SERVICE_FEE || 0
},
hasCustomBadge() {
return this.LNBITS_CUSTOM_BADGE && this.LNBITS_CUSTOM_BADGE != ''
},
customBadge() {
return this.LNBITS_CUSTOM_BADGE || ''
},
customBadgeColor() {
return this.LNBITS_CUSTOM_BADGE_COLOR || ''
},
title() {
return this.SITE_TITLE
},
titleIsLnbits() {
return this.SITE_TITLE == 'LNbits'
},
customLogoUrl() {
return this.USE_CUSTOM_LOGO || null
},
userPictureUrl() {
return this.g.user.config.picture
},
hasUserPicture() {
return this.g.user && this.g.user.config && this.g.user.config.picture
},
showAdmin() {
return this.g.user && (this.g.user.super_user || this.g.user.admin)
},
showVoidwallet() {
return this.g.user && this.VOIDWALLET == true
}
}
})
@@ -0,0 +1,104 @@
window.app.component('lnbits-home-logos', {
template: '#lnbits-home-logos',
mixins: [window.windowMixin],
data() {
return {
logos: [
{
href: 'https://github.com/ElementsProject/lightning',
lightSrc: '/static/images/clnl.png',
darkSrc: '/static/images/cln.png'
},
{
href: 'https://github.com/lightningnetwork/lnd',
lightSrc: '/static/images/lnd.png',
darkSrc: '/static/images/lnd.png'
},
{
href: 'https://opennode.com',
lightSrc: '/static/images/opennodel.png',
darkSrc: '/static/images/opennode.png'
},
{
href: 'https://lnpay.co/',
lightSrc: '/static/images/lnpayl.png',
darkSrc: '/static/images/lnpay.png'
},
{
href: 'https://github.com/rootzoll/raspiblitz',
lightSrc: '/static/images/blitzl.png',
darkSrc: '/static/images/blitz.png'
},
{
href: 'https://start9.com/',
lightSrc: '/static/images/start9l.png',
darkSrc: '/static/images/start9.png'
},
{
href: 'https://getumbrel.com/',
lightSrc: '/static/images/umbrell.png',
darkSrc: '/static/images/umbrel.png'
},
{
href: 'https://mynodebtc.com',
lightSrc: '/static/images/mynodel.png',
darkSrc: '/static/images/mynode.png'
},
{
href: 'https://github.com/shesek/spark-wallet',
lightSrc: '/static/images/sparkl.png',
darkSrc: '/static/images/spark.png'
},
{
href: 'https://voltage.cloud',
lightSrc: '/static/images/voltagel.png',
darkSrc: '/static/images/voltage.png'
},
{
href: 'https://breez.technology/sdk/',
lightSrc: '/static/images/breezl.png',
darkSrc: '/static/images/breez.png'
},
{
href: 'https://blockstream.com/lightning/greenlight/',
lightSrc: '/static/images/greenlightl.png',
darkSrc: '/static/images/greenlight.png'
},
{
href: 'https://getalby.com',
lightSrc: '/static/images/albyl.png',
darkSrc: '/static/images/alby.png'
},
{
href: 'https://zbd.gg',
lightSrc: '/static/images/zbdl.png',
darkSrc: '/static/images/zbd.png'
},
{
href: 'https://phoenix.acinq.co/server',
lightSrc: '/static/images/phoenixdl.png',
darkSrc: '/static/images/phoenixd.png'
},
{
href: 'https://boltz.exchange/',
lightSrc: '/static/images/boltzl.svg',
darkSrc: '/static/images/boltz.svg'
},
{
href: 'https://www.blink.sv/',
lightSrc: '/static/images/blink_logol.png',
darkSrc: '/static/images/blink_logo.png'
}
]
}
},
computed: {
showLogos() {
return (
this.LNBITS_DENOMINATION == 'sats' &&
this.SITE_TITLE == 'LNbits' &&
this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
)
}
}
})
@@ -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]
}
})
@@ -0,0 +1,37 @@
window.app.component('lnbits-language-dropdown', {
template: '#lnbits-language-dropdown',
mixins: [window.windowMixin],
methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
},
changeLanguage(newValue) {
this.g.locale = newValue
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
}
},
data() {
return {
langs: [
{value: 'en', label: 'English', display: '🇬🇧 EN'},
{value: 'de', label: 'Deutsch', display: '🇩🇪 DE'},
{value: 'es', label: 'Español', display: '🇪🇸 ES'},
{value: 'jp', label: '日本語', display: '🇯🇵 JP'},
{value: 'cn', label: '中文', display: '🇨🇳 CN'},
{value: 'fr', label: 'Français', display: '🇫🇷 FR'},
{value: 'it', label: 'Italiano', display: '🇮🇹 IT'},
{value: 'pi', label: 'Pirate', display: '🏴‍☠️ PI'},
{value: 'nl', label: 'Nederlands', display: '🇳🇱 NL'},
{value: 'we', label: 'Cymraeg', display: '🏴󠁧󠁢󠁷󠁬󠁳󠁿 CY'},
{value: 'pl', label: 'Polski', display: '🇵🇱 PL'},
{value: 'pt', label: 'Português', display: '🇵🇹 PT'},
{value: 'br', label: 'Português do Brasil', display: '🇧🇷 BR'},
{value: 'cs', label: 'Česky', display: '🇨🇿 CS'},
{value: 'sk', label: 'Slovensky', display: '🇸🇰 SK'},
{value: 'kr', label: '한국어', display: '🇰🇷 KR'},
{value: 'fi', label: 'Suomi', display: '🇫🇮 FI'}
]
}
}
})
@@ -0,0 +1,57 @@
window.app.component('lnbits-manage-extension-list', {
mixins: [window.windowMixin],
template: '#lnbits-manage-extension-list',
data() {
return {
extensions: [],
userExtensions: [],
searchTerm: ''
}
},
watch: {
'g.user.extensions': {
async handler() {
await this.loadExtensions()
}
},
searchTerm() {
this.filterUserExtensionsByTerm()
}
},
methods: {
map(data) {
const obj = {...data}
obj.url = ['/', obj.code, '/'].join('')
return obj
},
async loadExtensions() {
try {
const {data} = await LNbits.api.request('GET', '/api/v1/extension')
this.extensions = data
.map(extension => this.map(extension))
.sort((a, b) => a.name.localeCompare(b.name))
this.filterUserExtensionsByTerm()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
filterUserExtensionsByTerm() {
const userExts = this.g.user.extensions
this.userExtensions = this.extensions
.filter(o => userExts.includes(o.code))
.filter(o => {
if (this.searchTerm === '') return true
return `${o.code} ${o.name} ${o.short_description} ${o.url}`
.toLocaleLowerCase()
.includes(this.searchTerm.toLocaleLowerCase())
})
.map(obj => {
obj.isActive = window.location.pathname.startsWith(obj.url)
return obj
})
}
},
async created() {
await this.loadExtensions()
}
})
@@ -0,0 +1,9 @@
window.app.component('lnbits-manage-wallet-list', {
template: '#lnbits-manage-wallet-list',
mixins: [window.windowMixin],
data() {
return {
walletName: ''
}
}
})
@@ -1,7 +1,6 @@
window.app.component('payment-list', {
name: 'payment-list',
template: '#payment-list',
props: ['update', 'lazy', 'wallet'],
window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list',
props: ['wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() {
return {
@@ -32,9 +31,6 @@ window.app.component('payment-list', {
rowsNumber: 10
},
search: '',
filter: {
'status[ne]': 'failed'
},
loading: false
},
searchDate: {from: null, to: null},
@@ -125,7 +121,9 @@ window.app.component('payment-list', {
show: false,
payment: null,
preimage: null
}
},
selectedPayment: null,
filterLabels: []
}
},
computed: {
@@ -139,7 +137,7 @@ window.app.component('payment-list', {
return LNbits.utils.search(this.payments, q)
},
paymentsOmitter() {
if (this.$q.screen.lt.md && this.mobileSimple) {
if (this.$q.screen.lt.md && this.g.mobileSimple) {
return this.payments.length > 0 ? [this.payments[0]] : []
}
return this.payments
@@ -149,6 +147,49 @@ window.app.component('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 = {
@@ -157,32 +198,48 @@ window.app.component('payment-list', {
}
}
if (this.searchDate.from) {
this.paymentsTable.filter['time[ge]'] =
this.searchDate.from + 'T00:00:00'
this.paymentFilter['time[ge]'] = this.searchDate.from + 'T00:00:00'
}
if (this.searchDate.to) {
this.paymentsTable.filter['time[le]'] = this.searchDate.to + 'T23:59:59'
this.paymentFilter['time[le]'] = this.searchDate.to + 'T23:59:59'
}
this.fetchPayments()
},
searchByLabels(labels) {
if (!labels || labels.length === 0) {
this.clearLabelSeach()
return
}
this.filterLabels = labels
this.paymentsTable.filter['labels[every]'] = labels
this.fetchPayments()
},
clearDateSeach() {
this.searchDate = {from: null, to: null}
delete this.paymentsTable.filter['time[ge]']
delete this.paymentsTable.filter['time[le]']
delete this.paymentFilter['time[ge]']
delete this.paymentFilter['time[le]']
this.fetchPayments()
},
clearLabelSeach() {
this.filterLabels = []
delete this.paymentsTable.filter['labels[every]']
this.fetchPayments()
},
fetchPayments(props) {
this.$emit('filter-changed', {...this.paymentsTable.filter})
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
const params = LNbits.utils.prepareFilterQuery(
this.paymentsTable,
props,
this.paymentFilter
)
this.paymentsTable.loading = true
return LNbits.api
.getPayments(this.currentWallet, params)
.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
@@ -200,9 +257,7 @@ window.app.component('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
@@ -229,6 +284,41 @@ window.app.component('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 = ''
@@ -272,7 +362,7 @@ window.app.component('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) {
@@ -327,33 +417,74 @@ window.app.component('payment-list', {
},
handleFilterChanged() {
const {success, pending, failed, incoming, outgoing} = this.searchStatus
delete this.paymentsTable.filter['status[ne]']
delete this.paymentsTable.filter['status[eq]']
let paymentFilter = this.paymentFilter || {}
delete paymentFilter['status[ne]']
delete paymentFilter['status[eq]']
if (success && pending && failed) {
// No status filter
} else if (success && pending) {
this.paymentsTable.filter['status[ne]'] = 'failed'
paymentFilter['status[ne]'] = 'failed'
} else if (success && failed) {
this.paymentsTable.filter['status[ne]'] = 'pending'
paymentFilter['status[ne]'] = 'pending'
} else if (failed && pending) {
this.paymentsTable.filter['status[ne]'] = 'success'
} else if (success) {
this.paymentsTable.filter['status[eq]'] = 'success'
} else if (pending) {
this.paymentsTable.filter['status[eq]'] = 'pending'
} else if (failed) {
this.paymentsTable.filter['status[eq]'] = 'failed'
paymentFilter['status[ne]'] = 'success'
} else if (success && !pending && !failed) {
paymentFilter['status[eq]'] = 'success'
} else if (pending && !success && !failed) {
paymentFilter['status[eq]'] = 'pending'
} else if (failed && !success && !pending) {
paymentFilter['status[eq]'] = 'failed'
}
delete this.paymentsTable.filter['amount[ge]']
delete this.paymentsTable.filter['amount[le]']
if (incoming && outgoing) {
delete paymentFilter['amount[ge]']
delete paymentFilter['amount[le]']
if ((incoming && outgoing) || (!incoming && !outgoing)) {
// do nothing
} else if (incoming) {
this.paymentsTable.filter['amount[ge]'] = 0
} else if (outgoing) {
this.paymentsTable.filter['amount[le]'] = 0
} else if (incoming && !outgoing) {
paymentFilter['amount[ge]'] = 0
} else if (outgoing && !incoming) {
paymentFilter['amount[le]'] = 0
}
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
}
}
},
@@ -367,23 +498,11 @@ window.app.component('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()
}
})

Some files were not shown because too many files have changed in this diff Show More