Compare commits

...
Author SHA1 Message Date
dni ⚡ e8705650d8 update to version 1.4.2 2026-02-03 11:57:53 +01:00
Vlad Stan 8a9a96a52c chore: clean-up 2026-02-02 15:15:45 +02:00
Vlad Stan ad028b303c refactor: extract private method 2026-02-02 15:13:37 +02:00
dni ⚡ b744af1de5 fix: websocket with old balance was sent 2026-02-02 13:58:07 +01:00
45e773b66f feat: FIRST_INSTALL_TOKEN to help services like Umbrel secure the first_install endpoint (#3751)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-30 11:21:52 +01:00
f692ac6f12 refactor: payment.check_status() into check_payment_status(payment) (#3747)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-30 11:18:17 +01:00
dni ⚡andGitHub 78202a027b fix: uiCustomisation JS Error (#3752) 2026-01-30 09:27:54 +01:00
dni ⚡andGitHub 28fa0906a6 refactor: g.settings with a PublicSettings class for mapping, for reactive WINDOW_SETTINGS (#3644) 2026-01-30 09:19:01 +01:00
Vlad StanandGitHub 91354f8be4 feat: wallet featured button (#3740) 2026-01-29 10:49:19 +02:00
Vlad StanandGitHub 5f5d45e89f [feat] persist user ui customization (#3743) 2026-01-29 10:35:05 +02:00
dni ⚡andGitHub 52bb125a25 chore: remove ecdsa in favor of coincurve (#3746) 2026-01-29 10:02:16 +02:00
dni ⚡andGitHub 2ca1ed897c CI: use uv for publishing on pypi (#3745) 2026-01-29 08:54:42 +01:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
dni ⚡andGitHub ef9adc077b chore: update to v1.4.1 (#3731) 2026-01-20 13:01:16 +01:00
Vlad StanandGitHub ffad5d79c4 fix: undelete wallet (#3730) 2026-01-20 13:51:20 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
09c35eaca0 chore(deps-dev): bump filelock from 3.20.1 to 3.20.3 (#3721)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 09:17:26 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
52adc62bc4 chore(deps): bump filelock from 3.20.0 to 3.20.3 (#3720)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:40 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0efa173084 chore(deps): bump virtualenv from 20.35.4 to 20.36.1 (#3719)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:21 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
724f8c9f63 chore(deps-dev): bump virtualenv from 20.31.2 to 20.36.1 (#3718)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:05 +01:00
Vlad StanandGitHub c6981089e5 fix: handle pre-populated data (#3717) 2026-01-13 08:05:47 +01:00
Vlad Stan 13480027dd chore: bump release candidate 2026-01-12 13:33:53 +02:00
93f1006d3a feat: wire up paidreviews (#3656)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-12 13:17:39 +02:00
Tiago VasconcelosandGitHub 5490367179 fix: search input on extension page (#3705) 2026-01-12 12:39:48 +02:00
a185197e34 Fix: Update apipayments updated_at field when a payment is updated. (#3699)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-09 09:52:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa1bddcdd5 chore(deps-dev): bump werkzeug from 3.1.4 to 3.1.5 (#3714)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 06:26:32 +01:00
dni ⚡andGitHub 6d24ba55aa fix: /user/{user_id}/wallet should return deleted wallet (#3713) 2026-01-09 06:25:40 +01:00
Vlad StanandGitHub 5b2937672c fix: delete user with wallets (#3711) 2026-01-08 16:39:37 +01:00
Vlad StanandGitHub 957d9ce419 fix: password reset from UI (#3712) 2026-01-08 16:38:48 +01:00
PatMulliganandGitHub 0e21b77b74 fix(lndrest): use boolean for allow_self_payment instead of integer (#3700) 2026-01-08 09:54:05 +01:00
24e28911ab fix: greenlet import issue mac osx (#3698)
Co-authored-by: Pratik Patel <pratikpatelpp802@gmail.com>
2026-01-08 09:16:50 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e252d0485 chore(deps): bump urllib3 from 2.6.0 to 2.6.3 (#3706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 08:50:24 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e98550a125 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 08:50:10 +01:00
dni ⚡andGitHub bc55474859 fix: export qrcode was undefined (#3689) 2025-12-29 19:45:15 +01:00
dni ⚡andGitHub d985bdaadb chore: update breez liquid sdk (#3685) 2025-12-27 12:07:08 +01:00
118 changed files with 3845 additions and 2275 deletions
+4
View File
@@ -6,6 +6,10 @@
# The following settings are ONLY set in your .env file.
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Logging and Development ===
DEBUG=False
+11 -7
View File
@@ -44,15 +44,19 @@ jobs:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
+11 -7
View File
@@ -56,15 +56,19 @@ jobs:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
+12 -2
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from time import time
from typing import Any
@@ -304,8 +305,16 @@ async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
f"""
UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id
""", # noqa: S608
{
"new_id": new_checking_id,
"old_id": checking_id,
"now": int(time()),
},
)
@@ -314,6 +323,7 @@ async def update_payment(
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> None:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
+1
View File
@@ -204,6 +204,7 @@ async def get_user_from_account(
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
)
+10 -5
View File
@@ -45,17 +45,13 @@ async def update_wallet(
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Connection | None = None,
) -> None:
_clear_wallet_cache(wallet_id)
now = int(time())
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -69,6 +65,7 @@ async def delete_wallet(
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
_clear_wallet_cache(wallet_id)
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -78,6 +75,7 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None
) -> int | None:
_clear_wallet_cache(wallet_id)
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -294,3 +292,10 @@ async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
def _clear_wallet_cache(wallet_id):
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
+8
View File
@@ -848,3 +848,11 @@ async def m042_index_accounts(db: Connection):
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
"""
)
async def m043_add_ui_customization_to_accounts(db: Connection):
"""
Adds ui_customization column to accounts.
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
+48 -2
View File
@@ -12,7 +12,7 @@ from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, Field
from lnbits.helpers import (
download_url,
@@ -269,10 +269,30 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
return [
extension_releases = [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e:
logger.warning(e)
return []
@@ -777,6 +797,32 @@ class ExtensionDetailsRequest(BaseModel):
version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
+25
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import random
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
@@ -55,6 +57,29 @@ class DataField(BaseModel):
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
+10 -53
View File
@@ -6,23 +6,16 @@ from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
@@ -130,59 +123,23 @@ class Payment(BaseModel):
"fiat_"
)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
return await check_fiat_status(self, skip_internal_payment_notifications)
class PaymentFilters(FilterModel):
+3
View File
@@ -187,6 +187,7 @@ class Account(AccountId):
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -288,6 +289,7 @@ class User(BaseModel):
fiat_providers: list[str] = []
has_password: bool = False
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property
def wallet_ids(self) -> list[str]:
@@ -356,6 +358,7 @@ class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
first_install_token: str | None = Query(None)
class LoginUsr(BaseModel):
+4
View File
@@ -1,3 +1,4 @@
from .fiat_providers import check_fiat_status
from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
@@ -7,6 +8,7 @@ from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_payment_status,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
@@ -40,6 +42,8 @@ __all__ = [
"calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_fiat_status",
"check_payment_status",
"check_transaction_status",
"check_wallet_limits",
"check_webpush_settings",
+26 -12
View File
@@ -3,6 +3,7 @@ import io
from uuid import uuid4
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
@@ -29,17 +30,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
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)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
@@ -48,9 +39,32 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
if thumb_buffer
else None
),
data=contents,
)
await create_asset(asset)
return asset
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
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)
return thumb_buffer
except Exception as exc:
logger.warning(f"Failed to create thumbnail: {exc}")
return None
+44 -11
View File
@@ -20,6 +20,7 @@ from lnbits.helpers import (
camel_to_words,
download_url,
lowercase_first_letter,
snake_to_camel,
)
from lnbits.settings import settings
@@ -195,13 +196,29 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
shutil.copytree(
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data)
for py_file in py_files:
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f:
@@ -209,7 +226,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
template_path = Path(ext_stub_dir, "static", "index.js").as_posix()
rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data}
)
@@ -234,9 +251,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data",
ext_stub_dir,
)
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
rederer = _render_file(
template_path,
{
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData",
ext_stub_dir,
)
public_template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
template_path = public_template_path.as_posix()
if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else:
rederer = _render_file(
template_path,
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = {
"extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id,
"OwnerData": data.owner_data.name,
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path,
old_text=old_text,
new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"],
)
_rename_files_and_dirs_in_directory(
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": {
"name": data.owner_data.name,
"editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [
field.field_to_js()
for field in data.owner_data.fields
@@ -432,6 +450,18 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
},
"client_data": {
"name": data.client_data.name,
@@ -457,6 +487,9 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.client_data.fields],
"all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
},
"settings_data": {
"enabled": data.settings_data.enabled,
@@ -536,7 +569,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path):
def _is_excluded_dir(path: str) -> bool:
for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir):
return True
+40
View File
@@ -12,6 +12,12 @@ from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings
@@ -29,6 +35,40 @@ async def handle_fiat_payment_confirmation(
logger.warning(e)
async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal:
return FiatPaymentPendingStatus()
if payment.success:
return FiatPaymentSuccessStatus()
if payment.failed:
return FiatPaymentFailedStatus()
if not payment.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = payment.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(payment.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
return fiat_status
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
+40 -5
View File
@@ -25,6 +25,7 @@ from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount
from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@@ -47,6 +48,7 @@ from ..models import (
PaymentState,
Wallet,
)
from .fiat_providers import check_fiat_status
from .notifications import send_payment_notification_in_background
payment_lock = asyncio.Lock()
@@ -364,7 +366,7 @@ async def update_pending_payments(wallet_id: str):
async def update_pending_payment(
payment: Payment, conn: Connection | None = None
) -> Payment:
status = await payment.check_status()
status = await check_payment_status(payment)
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
@@ -618,7 +620,29 @@ async def check_transaction_status(
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
return await payment.check_status()
return await check_payment_status(payment)
async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal:
if payment.success:
return PaymentSuccessStatus()
if payment.failed:
return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if payment.is_out:
status = await funding_source.get_payment_status(payment.checking_id)
else:
status = await funding_source.get_invoice_status(payment.checking_id)
return status
async def get_payments_daily_stats(
@@ -752,7 +776,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
send_payment_notification_in_background(wallet, payment)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
@@ -825,7 +849,8 @@ async def _pay_external_invoice(
payment = await update_payment_success_status(
payment, payment_response, conn=conn
)
send_payment_notification_in_background(wallet, payment)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
@@ -868,7 +893,7 @@ async def _verify_external_payment(
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
status = await check_payment_status(payment)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
@@ -1033,3 +1058,13 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
await update_payment(payment)
return response
async def _send_payment_notification_in_background(
wallet_id: str, payment: Payment, conn: Connection | None = None
):
# fetch balance again
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
+4 -4
View File
@@ -57,11 +57,11 @@ async def api_get_asset(
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
"/{asset_id}/data",
name="Get user asset data",
summary="Get user asset data data by ID",
)
async def api_get_asset_binary(
async def api_get_asset_data(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
+83 -2
View File
@@ -4,9 +4,10 @@ import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Annotated
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
@@ -29,6 +30,7 @@ from lnbits.core.services.users import update_user_account
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import (
@@ -120,6 +122,63 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@auth_router.get("/acl")
async def api_get_user_acls(
request: Request,
@@ -408,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update")
@auth_router.patch("")
async def update(
data: UpdateUser, account: Account = Depends(check_account_exists)
) -> User | None:
@@ -424,10 +483,32 @@ async def update(
return await get_user_from_account(account)
@auth_router.patch("/ui")
async def update_ui_customization(
req: Request, account: Account = Depends(check_account_exists)
) -> Account:
ui_customization = await req.json()
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
if len(account.ui_customization or {}) > 1000 * 1024:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
)
await update_user_account(account)
return account
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
if settings.first_install_token:
if not data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
if settings.first_install_token != data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
+6 -5
View File
@@ -9,6 +9,7 @@ from lnbits.core.crud.payments import (
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_stripe_signature,
verify_paypal_webhook,
)
@@ -87,7 +88,7 @@ async def _handle_stripe_intent_session_completed(event: dict):
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
await check_fiat_status(payment)
async def _handle_stripe_checkout_session_completed(event: dict):
@@ -110,7 +111,7 @@ async def _handle_stripe_checkout_session_completed(event: dict):
payment = await get_standalone_payment(payment_hash)
if not payment:
raise ValueError(f"No payment found for hash: '{payment_hash}'.")
await payment.check_fiat_status()
await check_fiat_status(payment)
async def _handle_stripe_subscription_invoice_paid(event: dict):
@@ -155,7 +156,7 @@ async def _handle_stripe_subscription_invoice_paid(event: dict):
),
)
await payment.check_fiat_status()
await check_fiat_status(payment)
async def _get_stripe_subscription_payment_options(
@@ -192,7 +193,7 @@ async def handle_paypal_event(event: dict):
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
await check_fiat_status(payment)
return
if event_type in (
@@ -247,7 +248,7 @@ async def _handle_paypal_subscription_payment(resource: dict):
),
)
await payment.check_fiat_status()
await check_fiat_status(payment)
def _paypal_extract_payment_hash(resource: dict) -> str | None:
+53
View File
@@ -2,8 +2,10 @@ import sys
import traceback
from http import HTTPStatus
import httpx
from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException
from fastapi.requests import Request
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
@@ -14,10 +16,14 @@ from lnbits.core.models import (
)
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
@@ -34,6 +40,7 @@ from lnbits.core.services.extensions import (
install_extension,
uninstall_extension,
)
from lnbits.db import Page
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
@@ -589,3 +596,49 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
for ext in installable_exts
]
return extension_data
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
async with httpx.AsyncClient() as client:
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
resp.raise_for_status()
data = resp.json()
return [ExtensionReviewsStatus(**item) for item in data]
@extension_router.get(
"/reviews/{ext_id}",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
async with httpx.AsyncClient() as client:
query_string = str(request.query_params)
resp = await client.get(
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
)
resp.raise_for_status()
reviews = resp.json()
return Page(
data=[ExtensionReview(**item) for item in reviews["data"]],
total=reviews["total"],
)
@extension_router.put(
"/reviews",
dependencies=[Depends(check_account_exists)],
)
async def create_extension_review(
data: CreateExtensionReview,
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
)
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
+18 -31
View File
@@ -42,54 +42,38 @@ async def robots():
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
"/extensions/builder/preview/{ext_id}/{resource}",
name="extensions builder",
dependencies=[Depends(check_extension_builder)],
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
resource: str | None = None,
page_name: str | None = None,
user: User = Depends(check_user_exists),
) -> HTMLResponse:
) -> FileResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
html_file_name = "public_page.html"
html_file_path = Path(
file_name = "index"
if page_name == "public_page":
file_name = "public_page"
resource_path = Path(
settings.extension_builder_working_dir_path,
"extension_builder_stub",
ext_id,
working_dir_name,
ext_id,
"templates",
ext_id,
html_file_name,
"static",
)
html_file_full_path = Path(
settings.extension_builder_working_dir_path, html_file_path
)
file_ext = ".vue" if resource == "template" else ".js"
file_full_path = Path(resource_path, file_name + file_ext)
if not html_file_full_path.is_file():
return template_renderer().TemplateResponse(
request,
"error.html",
{
"status_code": 404,
"message": f"Extension {ext_id} not found, refresh Preview.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
},
)
if not file_full_path.is_file():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
response = FileResponse(file_full_path.absolute().as_posix())
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
@@ -208,6 +192,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
@generic_router.get(
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
+5 -7
View File
@@ -20,6 +20,7 @@ from lnbits.core.crud import (
update_admin_settings,
update_wallet,
)
from lnbits.core.crud.wallets import delete_wallet_by_id
from lnbits.core.models import (
AccountFilters,
AccountOverview,
@@ -157,11 +158,8 @@ async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
if user_id == settings.super_user:
raise HTTPException(
@@ -224,7 +222,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id)
return await get_wallets(user_id, deleted=None)
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
@@ -247,7 +245,7 @@ async def api_users_create_user_wallet(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet)
wal = await get_wallet(wallet, deleted=True)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
+7 -2
View File
@@ -337,7 +337,8 @@ class Database(Compat):
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
# Parse and add UTC timezone info
return datetime.strptime(value, f).replace(tzinfo=timezone.utc)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
@@ -756,7 +757,11 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value
# Ensure PostgreSQL datetime values have timezone info
if isinstance(value, datetime) and value.tzinfo is None:
_dict[key] = value.replace(tzinfo=timezone.utc)
else:
_dict[key] = value
continue
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
+1 -1
View File
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
logger.error(f"RequestValidationError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
content={"detail": [dict(e) for e in exc.errors()]},
)
@app.exception_handler(HTTPException)
+16 -50
View File
@@ -70,56 +70,11 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["static_url_for"] = static_url_for
t.env.globals["normalize_path"] = normalize_path
window_settings = {
"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),
"HIDE_API": settings.lnbits_hide_api,
"SITE_TITLE": settings.lnbits_site_title,
"SITE_TAGLINE": settings.lnbits_site_tagline,
"SITE_DESCRIPTION": settings.lnbits_site_description,
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_APPLE_TOUCH_ICON": settings.lnbits_apple_touch_icon,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"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,
"CACHE_KEY": settings.server_startup_time,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
for key, value in window_settings.items():
t.env.globals[key] = value
# used in base.html
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
t.env.globals["CURRENCIES"] = list(currencies.keys())
if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
@@ -133,6 +88,9 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
# backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
return t
@@ -390,6 +348,14 @@ def camel_to_snake(name: str) -> str:
return name.lower()
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
components = name.split("_")
camel = components[0] + "".join(x.capitalize() for x in components[1:])
if capitalize_first:
camel = camel[0].upper() + camel[1:]
return camel
def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
+126
View File
@@ -52,6 +52,13 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
URL for the paid reviews.
Regular users can view this URL (not secret).
""",
)
lnbits_extensions_manifests: list[str] = Field(
default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
@@ -245,6 +252,11 @@ class ThemesSettings(LNbitsSettings):
)
lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
lnbits_wallet_featured_button_label: str | None = Field(default=None)
lnbits_wallet_featured_button_url: str | None = Field(default=None)
lnbits_wallet_featured_button_icon: str | None = Field(default=None)
lnbits_custom_badge: str | None = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
@@ -299,6 +311,10 @@ class AssetSettings(LNbitsSettings):
"heic",
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -967,6 +983,7 @@ class EnvSettings(LNbitsSettings):
enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
@@ -1115,6 +1132,115 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
and ext_id in self.lnbits_all_extensions_ids
)
def to_public(self) -> PublicSettings:
return PublicSettings.from_settings(self)
class PublicSettings(BaseModel):
"""
PublicSettings is used for templating and public information.
WARNING: do not expose private information, PublicSettings is exposed publicly.
"""
allow_register: bool = Field(alias="allowRegister")
qr_logo: str = Field(alias="qrLogo")
site_title: str = Field(alias="siteTitle")
site_tagline: str = Field(alias="siteTagline")
site_description: str | None = Field(alias="siteDescription")
auth_methods: list[str] = Field(alias="authMethods")
keycloak_org: str | None = Field(alias="keycloakOrg")
keycloak_icon: str | None = Field(alias="keycloakIcon")
has_holdinvoice: bool = Field(alias="hasHoldinvoice")
has_nodemanager: bool = Field(alias="hasNodemanager")
show_nodemanager: bool = Field(alias="showNodemanager")
custom_logo: str | None = Field(alias="customLogo")
show_voidwallet: bool = Field(alias="showVoidwallet")
version: str = Field(alias="version")
show_home_page_elements: bool = Field(alias="showHomePageElements")
hide_api: bool = Field(alias="hideApi")
cache_key: str = Field(alias="cacheKey")
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
show_ad_space: bool = Field(alias="showAdSpace")
custom_image: str | None = Field(alias="customImage")
custom_badge: str | None = Field(alias="customBadge")
custom_badge_color: str | None = Field(alias="customBadgeColor")
service_fee: float = Field(alias="serviceFee")
service_fee_max: int = Field(alias="serviceFeeMax")
service_fee_wallet: str | None = Field(alias="serviceFeeWallet")
theme_options: list[str] = Field(alias="themeOptions")
default_reaction: str = Field(alias="defaultReaction")
default_theme: str = Field(alias="defaultTheme")
default_border: str = Field(alias="defaultBorder")
default_bgimage: str | None = Field(alias="defaultBgimage")
default_gradient: bool = Field(alias="defaultGradient")
denomination: str | None = Field()
extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
@classmethod
def from_settings(cls, settings: Settings):
ads = [x.split(";") for x in settings.lnbits_ad_space.split(",")]
return cls(
adSpace=ads,
adSpaceTitle=settings.lnbits_ad_space_title,
showAdSpace=settings.lnbits_ad_space_enabled and len(ads) > 0,
allowRegister=settings.new_accounts_allowed,
qrLogo=settings.lnbits_qr_logo,
siteDescription=settings.lnbits_site_description,
siteTitle=settings.lnbits_site_title,
siteTagline=settings.lnbits_site_tagline,
authMethods=settings.auth_allowed_methods,
keycloakOrg=settings.keycloak_client_custom_org,
keycloakIcon=settings.keycloak_client_custom_icon,
hasHoldinvoice=settings.has_holdinvoice,
hasNodemanager=settings.has_nodemanager,
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
customLogo=settings.lnbits_custom_logo,
showVoidwallet=settings.lnbits_backend_wallet_class == "VoidWallet",
version=settings.version,
showHomePageElements=settings.lnbits_show_home_page_elements,
hideApi=settings.lnbits_hide_api,
cacheKey=str(settings.server_startup_time),
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
customBadgeColor=settings.lnbits_custom_badge_color,
serviceFee=settings.lnbits_service_fee,
serviceFeeMax=settings.lnbits_service_fee_max,
serviceFeeWallet=settings.lnbits_service_fee_wallet,
themeOptions=settings.lnbits_theme_options,
defaultReaction=settings.lnbits_default_reaction,
defaultTheme=settings.lnbits_default_theme,
defaultBorder=settings.lnbits_default_border,
defaultGradient=settings.lnbits_default_gradient,
defaultBgimage=settings.lnbits_default_bgimage,
denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies,
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
)
class SuperSettings(EditableSettings):
super_user: str
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
+31
View File
@@ -187,6 +187,25 @@ window.localisation.en = {
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
reviews_url_hint:
'Full PaidReviews URL including the settings id (e.g. https://example.com/paidreviews/SETTINGS_ID)',
reviews_open: 'View reviews',
reviews_leave: 'Leave a review',
reviews_name: 'Your name',
reviews_comment: 'Your review',
reviews_rating: 'Rating',
reviews_submit: 'Submit review',
reviews_loading: 'Loading reviews...',
reviews_refresh: 'Refresh reviews',
reviews_error_load: 'Could not load reviews',
reviews_url_not_configured: 'Reviews URL not configured',
reviews_pay_invoice: 'Pay invoice',
reviews_invoice_paid: 'Invoice paid',
reviews_invoice_title: 'Pay this invoice to submit your review',
reviews_count: 'Reviews',
no_reviews: 'No reviews yet',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:',
@@ -499,6 +518,7 @@ window.localisation.en = {
hash: 'Hash: ',
welcome_lnbits: 'Welcome to LNbits',
setup_su_account: 'Set up the Superuser account below.',
first_install_token: 'First Install Token (optional)',
create_ticker_converter: 'Create Currency Ticker Converter',
enable_audit: 'Enable Audit',
recommended: 'Recommended',
@@ -639,6 +659,15 @@ window.localisation.en = {
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
ui_default_wallet_name: 'Default Wallet Name',
ui_default_theme: 'Default Theme',
wallet_featured_button_label: 'Wallet Featured Button Label',
wallet_featured_button_label_hint:
'Show featured button on the wallet homepage',
wallet_featured_button_url: 'Featured Button URL',
wallet_featured_button_url_hint:
'On click the button will open this URL. Leave empty to hide the button.',
wallet_featured_button_icon: 'Featured Button Icon',
wallet_featured_button_icon_hint:
'Icon shown on the featured button (check quasar icons)',
lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token',
@@ -676,6 +705,8 @@ window.localisation.en = {
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods',
+16
View File
@@ -123,6 +123,19 @@ window._lnbitsApi = {
url: '/api/v1/auth/logout'
})
},
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
@@ -141,6 +154,9 @@ window._lnbitsApi = {
name: name
})
},
updateUiCustomization(data = {}) {
return this.request('patch', '/api/v1/auth/ui', null, data)
},
resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => {
+3 -1
View File
@@ -13,7 +13,9 @@ window.LNbits = {
wallets: data.wallets,
fiat_providers: data.fiat_providers || [],
super_user: data.super_user,
extra: data.extra ?? {}
extra: data.extra ?? {},
hasPassword: data.has_password ?? false,
uiCustomization: data.ui_customization || {}
}
const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
+21 -40
View File
@@ -3,44 +3,38 @@ window.app.component(QrcodeVue)
window.app.component('lnbits-extension-rating', {
template: '#lnbits-extension-rating',
name: 'lnbits-extension-rating',
props: ['rating']
})
window.app.component('lnbits-fsat', {
template: '<span>{{ fsat }}</span>',
props: {
amount: {
rating: {
type: Number,
default: 0
},
count: {
type: Number,
default: null
},
clickable: {
type: Boolean,
default: false
}
},
computed: {
fsat() {
return LNbits.utils.formatSat(this.amount)
displayRating() {
return Math.round((this.rating || 0) * 2) / 2
},
hasData() {
return this.count !== null && this.count !== undefined
}
},
methods: {
handleClick() {
if (!this.clickable) return
this.$emit('click')
}
}
})
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
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
@@ -54,10 +48,8 @@ window.app.component('lnbits-manage', {
})
window.app.component('lnbits-payment-details', {
mixins: [window.windowMixin],
template: '#lnbits-payment-details',
props: ['payment'],
mixins: [window.windowMixin],
computed: {
hasPreimage() {
return (
@@ -101,7 +93,6 @@ window.app.component('lnbits-payment-details', {
})
window.app.component('lnbits-lnurlpay-success-action', {
mixins: [window.windowMixin],
template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'],
data() {
@@ -121,7 +112,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'],
data() {
return {
@@ -292,7 +282,6 @@ window.app.component('lnbits-notifications-btn', {
window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'modelValue'],
data() {
return {
@@ -325,7 +314,6 @@ window.app.component('lnbits-dynamic-fields', {
window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
@@ -356,7 +344,6 @@ window.app.component('lnbits-dynamic-chips', {
window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'small_btn'],
computed: {
admin() {
@@ -395,7 +382,6 @@ window.app.component('lnbits-update-balance', {
window.app.component('user-id-only', {
template: '#user-id-only',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authAction: String,
@@ -439,7 +425,6 @@ window.app.component('user-id-only', {
window.app.component('username-password', {
template: '#username-password',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authMethods: Array,
@@ -460,9 +445,7 @@ window.app.component('username-password', {
username: this.userName,
password: this.password_1,
passwordRepeat: this.password_2,
reset_key: this.resetKey,
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
reset_key: this.resetKey
}
},
methods: {
@@ -677,7 +660,6 @@ window.app.component('lnbits-stat', {
window.app.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [window.windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
@@ -756,7 +738,6 @@ window.app.component('lnbits-node-info', {
showDialog: false
}
},
mixins: [window.windowMixin],
methods: {
shortenNodeId(nodeId) {
return nodeId
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-assets-config', {
props: ['form-data'],
template: '#lnbits-admin-assets-config',
mixins: [window.windowMixin],
data() {
return {
newAllowedAssetMimeType: '',
@@ -17,6 +16,7 @@ window.app.component('lnbits-admin-assets-config', {
this.newAllowedAssetMimeType
)
this.newAllowedAssetMimeType = ''
this.formData.touch = null
}
},
removeAllowedAssetMimeType(type) {
@@ -24,18 +24,21 @@ window.app.component('lnbits-admin-assets-config', {
if (index !== -1) {
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
}
this.formData.touch = null
},
addNewNoLimitUser() {
if (this.newNoLimitUser) {
this.removeNoLimitUser(this.newNoLimitUser)
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
this.newNoLimitUser = ''
this.formData.touch = null
}
},
removeNoLimitUser(user) {
if (user) {
this.formData.lnbits_assets_no_limit_users =
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
this.formData.touch = null
}
}
}
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-audit', {
props: ['form-data'],
template: '#lnbits-admin-audit',
mixins: [window.windowMixin],
data() {
return {
formAddIncludePath: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-exchange-providers', {
props: ['form-data'],
template: '#lnbits-admin-exchange-providers',
mixins: [window.windowMixin],
data() {
return {
exchangeData: {
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-extensions', {
props: ['form-data'],
template: '#lnbits-admin-extensions',
mixins: [window.windowMixin],
data() {
return {
formAddExtensionsManifest: ''
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-fiat-providers', {
props: ['form-data'],
template: '#lnbits-admin-fiat-providers',
mixins: [window.windowMixin],
data() {
return {
formAddStripeUser: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-admin-funding-sources', {
template: '#lnbits-admin-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
mixins: [window.windowMixin],
data() {
return {
auditData: []
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-notifications', {
props: ['form-data'],
template: '#lnbits-admin-notifications',
mixins: [window.windowMixin],
data() {
return {
nostrNotificationIdentifier: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-security', {
props: ['form-data'],
template: '#lnbits-admin-security',
mixins: [window.windowMixin],
data() {
return {
logs: [],
@@ -1,5 +1,4 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server',
mixins: [window.windowMixin]
template: '#lnbits-admin-server'
})
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-site-customisation', {
props: ['form-data'],
template: '#lnbits-admin-site-customisation',
mixins: [window.windowMixin],
data() {
return {
lnbits_theme_options: [
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-users', {
props: ['form-data'],
template: '#lnbits-admin-users',
mixins: [window.windowMixin],
data() {
return {
formAddUser: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-disclaimer', {
template: '#lnbits-disclaimer',
mixins: [window.windowMixin],
computed: {
showDisclaimer() {
return !g.disclaimerShown && g.isUserAuthorized
+1 -2
View File
@@ -1,4 +1,3 @@
window.app.component('lnbits-drawer', {
template: '#lnbits-drawer',
mixins: [window.windowMixin]
template: '#lnbits-drawer'
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-error', {
template: '#lnbits-error',
mixins: [window.windowMixin],
props: ['dynamic', 'code', 'message'],
computed: {
isExtension() {
+3 -7
View File
@@ -1,15 +1,11 @@
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}`
return `${this.g.settings.siteTitle}, ${this.g.settings.siteTagline}`
},
showFooter() {
return this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
version() {
return this.$t('lnbits_version') + ': ' + this.g.settings.version
}
}
})
@@ -1,4 +1,3 @@
window.app.component('lnbits-header-wallets', {
template: '#lnbits-header-wallets',
mixins: [window.windowMixin]
template: '#lnbits-header-wallets'
})
+23 -40
View File
@@ -1,49 +1,9 @@
window.app.component('lnbits-header', {
template: '#lnbits-header',
mixins: [window.windowMixin],
computed: {
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
},
showAdmin() {
return this.g.user && (this.g.user.super_user || this.g.user.admin)
},
showVoidwallet() {
return this.g.user && this.VOIDWALLET == true
},
displayName() {
return (
this.g.user?.extra?.display_name ||
@@ -61,5 +21,28 @@ window.app.component('lnbits-header', {
return 'User'
}
}
},
methods: {
async stopImpersonation() {
try {
await LNbits.api.stopImpersonation()
LNbits.utils.restoreLocalStorage('impersonation')
window.location = '/users'
} catch (e) {
console.warn(e)
}
},
async handleLanguageChanged(lang) {
try {
await LNbits.api.updateUiCustomization({locale: lang.locale})
this.$q.notify({
type: 'positive',
message: 'Language Updated',
caption: lang.locale
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-home-logos', {
template: '#lnbits-home-logos',
mixins: [window.windowMixin],
data() {
return {
logos: [
@@ -96,8 +95,8 @@ window.app.component('lnbits-home-logos', {
showLogos() {
return (
this.g.isSatsDenomination &&
this.SITE_TITLE == 'LNbits' &&
this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
this.g.settings.siteTitle == 'LNbits' &&
this.g.settings.showHomePageElements == true
)
}
}
@@ -1,7 +1,6 @@
window.app.component('lnbits-label-selector', {
template: '#lnbits-label-selector',
props: ['labels'],
mixins: [window.windowMixin],
data() {
return {
labelFilter: '',
@@ -1,6 +1,16 @@
window.app.component('lnbits-language-dropdown', {
template: '#lnbits-language-dropdown',
mixins: [window.windowMixin],
computed: {
currentLanguage() {
return (
this.langs.find(lang => lang.value === window.i18n.global.locale) || {
value: 'en',
label: 'English',
display: '🇬🇧 EN'
}
)
}
},
methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
@@ -9,6 +19,7 @@ window.app.component('lnbits-language-dropdown', {
this.g.locale = newValue
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
this.$emit('language-changed', newValue)
}
},
data() {
@@ -1,5 +1,4 @@
window.app.component('lnbits-manage-extension-list', {
mixins: [window.windowMixin],
template: '#lnbits-manage-extension-list',
data() {
return {
@@ -1,6 +1,5 @@
window.app.component('lnbits-manage-wallet-list', {
template: '#lnbits-manage-wallet-list',
mixins: [window.windowMixin],
data() {
return {
activeWalletId: null
@@ -1,7 +1,6 @@
window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list',
props: ['wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() {
return {
payments: [],
@@ -1,6 +1,5 @@
window.app.component('lnbits-qrcode-lnurl', {
template: '#lnbits-qrcode-lnurl',
mixins: [window.windowMixin],
props: {
url: {
required: true,
+1 -2
View File
@@ -1,5 +1,4 @@
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue: QrcodeVue.default
@@ -35,7 +34,7 @@ window.app.component('lnbits-qrcode', {
},
logo: {
type: String,
default: LNBITS_QR_LOGO
default: window.g.settings.qrLogo || null
}
},
data() {
+11 -1
View File
@@ -1,5 +1,4 @@
window.app.component('lnbits-theme', {
mixins: [window.windowMixin],
watch: {
'g.walletFlip'(val) {
this.$q.localStorage.setItem('lnbits.walletFlip', val)
@@ -10,6 +9,10 @@ window.app.component('lnbits-theme', {
'g.disclaimerShown'(val) {
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
},
'g.locale'(val) {
this.$q.localStorage.setItem('lnbits.lang', val)
window.i18n.global.locale = val
},
'g.isFiatPriority'(val) {
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
},
@@ -124,6 +127,13 @@ window.app.component('lnbits-theme', {
if (this.g.mobileSimple === true) {
document.body.classList.add('mobile-simple')
}
Object.entries(this.g.user?.uiCustomization || {}).forEach(
([key, value]) => {
if (key in this.g) {
this.g[key] = value
}
}
)
this.checkUrlParams()
}
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-api-docs', {
template: '#lnbits-wallet-api-docs',
mixins: [window.windowMixin],
methods: {
resetKeys() {
LNbits.utils
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-charts', {
template: '#lnbits-wallet-charts',
mixins: [window.windowMixin],
props: ['paymentFilter', 'chartConfig'],
data() {
return {
@@ -1,9 +1,10 @@
window.app.component('lnbits-wallet-extra', {
template: '#lnbits-wallet-extra',
mixins: [window.windowMixin],
props: ['chartConfig'],
data() {
return {}
computed: {
exportUrl() {
return `${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`
}
},
methods: {
handleSendLnurl(lnurl) {
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-icon', {
template: '#lnbits-wallet-icon',
mixins: [window.windowMixin],
data() {
return {
icon: {
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-new', {
template: '#lnbits-wallet-new',
mixins: [window.windowMixin],
data() {
return {
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-paylinks', {
template: '#lnbits-wallet-paylinks',
mixins: [window.windowMixin],
data() {
return {
storedPaylinks: []
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-share', {
template: '#lnbits-wallet-share',
mixins: [window.windowMixin],
computed: {
walletApprovedShares() {
return this.g.wallet.extra.shared_with.filter(
+35 -39
View File
@@ -9,25 +9,22 @@ const localStore = (key, defaultValue) => {
}
window.g = Vue.reactive({
errorCode: null,
errorMessage: null,
user: null,
wallet: null,
isPublicPage: true,
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
offline: !navigator.onLine,
hasCamera: false,
visibleDrawer: false,
extensions: WINDOW_SETTINGS.EXTENSIONS,
fiatBalance: 0,
exchangeRate: 0,
fiatTracking: false,
payments: [],
walletEventListeners: [],
updatePayments: false, // used for updating the lnbits-payment-list
updatePaymentsHash: false, // used for closing the receive dialog
currencies: WINDOW_SETTINGS.LNBITS_CURRENCIES ?? [],
allowedCurrencies: WINDOW_SETTINGS.LNBITS_ALLOWED_CURRENCIES ?? [],
// vars from server
settings: SETTINGS,
currencies: CURRENCIES,
extensions: SETTINGS.extensions,
allowedCurrencies: SETTINGS.allowedCurrencies,
denomination: SETTINGS.denomination,
isSatsDenomination: SETTINGS.denomination == 'sats',
// local storage vars
themeChoice: localStore('lnbits.theme', SETTINGS.defaultTheme),
borderChoice: localStore('lnbits.border', SETTINGS.defaultBorder),
gradientChoice: localStore('lnbits.gradientBg', SETTINGS.defaultGradient),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore(
'lnbits.backgroundImage',
SETTINGS.defaultBgimage || ''
),
locale: localStore('lnbits.lang', navigator.languages[1] ?? 'en'),
disclaimerShown: localStore('lnbits.disclaimerShown', false),
isFiatPriority: localStore('lnbits.isFiatPriority', false),
@@ -35,26 +32,25 @@ window.g = Vue.reactive({
walletFlip: localStore('lnbits.walletFlip', false),
lastActiveWallet: localStore('lnbits.lastActiveWallet', null),
darkChoice: localStore('lnbits.darkMode', true),
themeChoice: localStore('lnbits.theme', WINDOW_SETTINGS.LNBITS_DEFAULT_THEME),
borderChoice: localStore(
'lnbits.border',
WINDOW_SETTINGS.LNBITS_DEFAULT_BORDER || 'hard-border'
),
gradientChoice: localStore(
'lnbits.gradientBg',
WINDOW_SETTINGS.LNBITS_DEFAULT_GRADIENT || false
),
reactionChoice: localStore(
'lnbits.reactions',
WINDOW_SETTINGS.LNBITS_DEFAULT_REACTION || 'confettiBothSides'
),
bgimageChoice: localStore(
'lnbits.backgroundImage',
WINDOW_SETTINGS.LNBITS_DEFAULT_BGIMAGE || ''
),
ads: WINDOW_SETTINGS.AD_SPACE.split(',').map(ad => ad.split(';')),
denomination: WINDOW_SETTINGS.LNBITS_DENOMINATION,
isSatsDenomination: WINDOW_SETTINGS.LNBITS_DENOMINATION == 'sats',
// cookie vars
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
isUserImpersonated: !!Quasar.Cookies.get('is_lnbits_user_impersonated'),
// frontend vars
errorCode: null,
errorMessage: null,
user: null,
wallet: null,
isPublicPage: true,
offline: !navigator.onLine,
hasCamera: false,
visibleDrawer: false,
fiatBalance: 0,
exchangeRate: 0,
fiatTracking: false,
payments: [],
walletEventListeners: [],
updatePayments: false, // used for updating the lnbits-payment-list
updatePaymentsHash: false, // used for closing the receive dialog
scanner: null,
newWalletType: null
})
+6 -2
View File
@@ -106,6 +106,11 @@ const routes = [
name: 'ExtensionsBuilder',
component: PageExtensionBuilder
},
{
path: '/extensions/builder/preview',
name: 'ExtensionsBuilderPreview',
component: PageExtensionBuilderPreview
},
{
path: '/extensions',
name: 'Extensions',
@@ -152,8 +157,7 @@ window.app.mixin({
return {
api: window._lnbitsApi,
utils: window._lnbitsUtils,
g: window.g,
...WINDOW_SETTINGS
g: window.g
}
},
// backwards compatibility for extensions, should not be used in the future
+34 -17
View File
@@ -1,6 +1,5 @@
window.PageAccount = {
template: '#page-account',
mixins: [window.windowMixin],
data() {
return {
untouchedUser: null,
@@ -40,6 +39,15 @@ window.PageAccount = {
color: 'pink-3'
}
],
defaultSiteCustomisation: {
locale: 'en',
themeChoice: 'salvador',
bgimageChoice: '',
gradientChoice: true,
darkChoice: true,
borderChoice: 'hard-border',
reactionChoice: 'confettiBothSides'
},
reactionOptions: [
'None',
'confettiBothSides',
@@ -214,26 +222,18 @@ window.PageAccount = {
}
},
methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
},
changeLanguage(newValue) {
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
},
async updateAccount() {
try {
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/update',
null,
{
user_id: this.g.user.id,
username: this.g.user.username,
email: this.g.user.email,
extra: this.g.user.extra
}
)
const {data} = await LNbits.api.request('PATCH', '/api/v1/auth', null, {
user_id: this.g.user.id,
username: this.g.user.username,
email: this.g.user.email,
extra: this.g.user.extra
})
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
this.hasUsername = !!data.username
Quasar.Notify.create({
@@ -610,7 +610,7 @@ window.PageAccount = {
}
},
copyAssetLinkToClipboard(asset) {
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/binary`
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/data`
this.utils.copyText(assetUrl)
},
addUserLabel() {
@@ -681,6 +681,23 @@ window.PageAccount = {
l => l.name !== label.name
)
})
},
async siteCustomisationChanged(options = {}) {
try {
Object.entries(options || {}).forEach(([key, value]) => {
if (key in this.g) {
this.g[key] = value
}
})
await LNbits.api.updateUiCustomization(options)
this.$q.notify({
type: 'positive',
message: 'UI Customization updated.'
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
},
@@ -694,7 +711,7 @@ window.PageAccount = {
await this.getUserAssets()
// filter out themes that are not allowed
this.themeOptions = this.themeOptions.filter(theme =>
this.LNBITS_THEME_OPTIONS.includes(theme.name)
this.g.settings.themeOptions.includes(theme.name)
)
}
}
-1
View File
@@ -1,6 +1,5 @@
window.PageAdmin = {
template: '#page-admin',
mixins: [windowMixin],
data() {
return {
tab: 'funding',
-1
View File
@@ -1,6 +1,5 @@
window.PageAudit = {
template: '#page-audit',
mixins: [window.windowMixin],
data() {
return {
chartsReady: false,
+1 -2
View File
@@ -1,4 +1,3 @@
window.PageError = {
template: '#page-error',
mixins: [window.windowMixin]
template: '#page-error'
}
+233 -24
View File
@@ -1,6 +1,5 @@
window.PageExtensions = {
template: '#page-extensions',
mixins: [window.windowMixin],
data() {
return {
extbuilderEnabled: false,
@@ -26,7 +25,63 @@ window.PageExtensions = {
selectedRelease: null,
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null
paylinkWebsocket: null,
searchToggle: false,
reviewsUrl: null,
reviewsDialog: {
show: false,
extension: null,
loading: false,
submitting: false,
form: {
name: '',
rating: 0,
comment: ''
},
error: null
},
reviews: [],
reviewsTable: {
loading: false,
columns: [
{
name: 'name',
align: 'left',
label: this.$t('Name'),
field: 'name',
sortable: true
},
{
name: 'comment',
align: 'left',
label: this.$t('Comment'),
field: 'comment'
},
{
name: 'created_at',
align: 'left',
label: this.$t('Date'),
field: 'created_at'
},
{
name: 'rating',
align: 'right',
label: 'Rating',
field: 'rating'
}
],
pagination: {
rowsPerPage: 5,
sortBy: 'created_at',
descending: true,
page: 1
}
},
paymentDialog: {
show: false,
invoice: '',
hash: ''
}
}
},
watch: {
@@ -71,7 +126,6 @@ window.PageExtensions = {
// the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs()
const extension = this.selectedExtension
this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false
release.payment_hash =
@@ -285,8 +339,7 @@ window.PageExtensions = {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/releases`,
this.g.user.wallets[0].adminkey
`/api/v1/extension/${extension.id}/releases`
)
this.selectedExtensionRepos = data.reduce((repos, release) => {
@@ -324,6 +377,9 @@ window.PageExtensions = {
if (!detailsLink) {
return
}
// keep a reference to the extension so we can show rating stats
this.selectedExtension =
this.extensions.find(ext => ext.id === extId) || this.selectedExtension
this.selectedExtensionDetails = null
this.showExtensionDetailsDialog = true
this.slide = 0
@@ -332,8 +388,7 @@ window.PageExtensions = {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`,
this.g.user.wallets[0].inkey
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`
)
this.selectedExtensionDetails = data
@@ -454,7 +509,7 @@ window.PageExtensions = {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/${extId}/invoice/install`,
this.g.user.wallets[0].adminkey,
null,
{
ext_id: extId,
archive: release.archive,
@@ -470,7 +525,7 @@ window.PageExtensions = {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/${extId}/invoice/enable`,
this.g.user.wallets[0].adminkey,
null,
{
amount
}
@@ -561,8 +616,7 @@ window.PageExtensions = {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
this.g.user.wallets[0].adminkey
`/api/v1/extension/release/${org}/${repo}/${release.version}`
)
release.loaded = true
release.is_version_compatible = data.is_version_compatible
@@ -587,18 +641,13 @@ window.PageExtensions = {
continue
}
ext.inProgress = true
await LNbits.api.request(
'POST',
`/api/v1/extension`,
this.g.user.wallets[0].adminkey,
{
ext_id: ext.id,
archive: ext.latestRelease.archive,
source_repo: ext.latestRelease.source_repo,
payment_hash: ext.latestRelease.payment_hash,
version: ext.latestRelease.version
}
)
await LNbits.api.request('POST', `/api/v1/extension`, null, {
ext_id: ext.id,
archive: ext.latestRelease.archive,
source_repo: ext.latestRelease.source_repo,
payment_hash: ext.latestRelease.payment_hash,
version: ext.latestRelease.version
})
count++
ext.isAvailable = true
ext.isInstalled = true
@@ -623,6 +672,163 @@ window.PageExtensions = {
})
this.showUpdateAllDialog = false
},
formatAvg(raw) {
const val = Number(raw || 0)
return Math.round((val / 2 / 100) * 2) / 2
},
async loadReviewStats() {
if (!this.reviewsUrl) {
console.info('Extension reviews are not configured')
return
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/reviews/tags`
)
const map = {}
data.forEach(stat => {
map[stat.tag] = stat
})
this.extensions.forEach(ext => {
ext.reviewStats = map[ext.id] || null
})
this.filterExtensions(this.searchTerm, this.tab)
} catch (error) {
console.warn(error)
}
},
async openReviews(extension) {
const targetExtension =
extension ||
(this.selectedExtensionDetails
? this.extensions.find(e => e.id === this.selectedExtensionDetails.id)
: null)
if (!targetExtension) {
return
}
if (!this.reviewsUrl) {
Quasar.Notify.create({
type: 'warning',
message: this.$t('reviews_url_not_configured')
})
return
}
this.reviewsDialog.extension = targetExtension
this.selectedExtension = extension
this.reviewsDialog.show = true
await this.getTagReviews()
},
async getTagReviews(props) {
if (!this.reviewsUrl) {
Quasar.Notify.create({
type: 'warning',
message: this.$t('reviews_url_not_configured')
})
return
}
try {
this.reviewsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.reviewsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/reviews/${this.selectedExtension.id}?${params}`
)
this.reviews = data.data
this.reviewsTable.pagination.rowsNumber = data.total
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.reviewsTable.loading = false
}
},
formatReviewDate(val) {
if (!val) return ''
const numeric = Number(val)
if (!Number.isNaN(numeric)) {
return this.utils.formatTimestamp(numeric)
}
return this.utils.formatDate(val)
},
async submitReview() {
if (!this.reviewsDialog.extension || !this.reviewsUrl) {
return
}
this.reviewsDialog.submitting = true
try {
const payload = {
tag: this.reviewsDialog.extension.id,
name: this.reviewsDialog.form.name,
rating: this.reviewsDialog.form.rating * 100,
comment: this.reviewsDialog.form.comment
}
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/reviews`,
null,
payload
)
if (data.payment_request) {
this.openInvoiceDialog(data.payment_request, data.payment_hash)
} else {
Quasar.Notify.create({
type: 'positive',
message: 'Review submitted'
})
this.resetReviewForm()
await this.getTagReviews()
await this.loadReviewStats()
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.reviewsDialog.submitting = false
}
},
openInvoiceDialog(invoice, hash) {
this.paymentDialog.invoice = invoice
this.paymentDialog.hash = hash
this.paymentDialog.show = true
this.listenForPayment(hash)
},
resetReviewForm() {
this.reviewsDialog.form = {
name: '',
rating: 0,
comment: ''
}
this.paymentDialog = {
show: false,
invoice: '',
hash: ''
}
},
listenForPayment(hash) {
try {
const base = new URL(this.reviewsUrl)
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:'
base.pathname = `/api/v1/ws/${hash}`
const ws = new WebSocket(base)
ws.addEventListener('message', async () => {
Quasar.Notify.create({
type: 'positive',
message: this.$t('reviews_invoice_paid')
})
this.paymentDialog.show = false
this.resetReviewForm()
setTimeout(async () => {
await this.getTagReviews()
}, 1000)
await this.loadReviewStats()
ws.close()
})
} catch (error) {
console.warn(error)
}
},
async fetchAllExtensions() {
try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
@@ -636,7 +842,8 @@ window.PageExtensions = {
},
async created() {
this.extensions = await this.fetchAllExtensions()
this.extbuilderEnabled = this.g.user.admin || this.LNBITS_EXT_BUILDER
this.extbuilderEnabled = this.g.user.admin || this.g.settings.extBuilder
this.reviewsUrl = this.g.settings.extensionsReviewsUrl
if (this.g.user.extensions.length === 0) {
this.tab = 'all'
@@ -653,6 +860,8 @@ window.PageExtensions = {
this.hasNewVersion(ext)
)
await this.loadReviewStats()
this.filterExtensions(this.searchTerm, this.tab)
}
}
+18 -7
View File
@@ -1,6 +1,5 @@
window.PageExtensionBuilder = {
template: '#page-extension-builder',
mixins: [windowMixin],
data() {
return {
step: 1,
@@ -288,11 +287,10 @@ window.PageExtensionBuilder = {
`/api/v1/extension/${stub_ext_id}/releases`
)
this.extensionStubVersions = data.sort((a, b) =>
a.version < b.version ? 1 : -1
)
this.extensionData.stub_version = this.extensionStubVersions[0]
? this.extensionStubVersions[0].version
this.extensionStubVersions = data
const validVersions = data.filter(v => v.is_version_compatible)
this.extensionData.stub_version = validVersions[0]
? validVersions[0].version
: ''
} catch (error) {
LNbits.utils.notifyApiError(error)
@@ -311,7 +309,20 @@ window.PageExtensionBuilder = {
iframeDoc.body.style.transform = 'scale(0.8)'
iframeDoc.body.style.transformOrigin = 'center top'
}
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
let componentName =
'Page' +
this.extensionData.id
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
if (previewPageName === 'public_page') componentName += 'Public'
iframe.src =
`/extensions/builder/preview?` +
`ext_id=${this.extensionData.id}` +
`&page=${previewPageName}` +
`&component=${componentName}`
},
initBasicData() {
this.extensionData.owner_data.fields = [
@@ -0,0 +1,46 @@
window.PageExtensionBuilderPreview = {
template: '#page-extension-builder-preview',
mixins: [windowMixin],
watch: {
name: 'reload'
},
data() {
return {
extId: '',
pageName: '',
componentName: null
}
},
methods: {
async reload() {
await LNbits.utils.loadTemplate(
`/extensions/builder/preview/${this.extId}/template?page_name=${this.pageName}`
)
await LNbits.utils.loadScript(
`/extensions/builder/preview/${this.extId}/component?page_name=${this.pageName}`
)
this._component = window[this.componentName]
console.log(
'LNbits preview reloaded componentName:',
this.componentName,
!!this._component
)
this.$forceUpdate()
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search)
this.extId = urlParams.get('ext_id') || ''
this.pageName = urlParams.get('page') || ''
this.componentName = urlParams.get('component') || ''
await this.reload()
},
render() {
if (this._component) {
return Vue.h(this._component)
}
return Vue.h('div', 'Loading...')
}
}
+16 -11
View File
@@ -1,6 +1,5 @@
window.PageFirstInstall = {
template: '#page-first-install',
mixins: [window.windowMixin],
data() {
return {
loginData: {
@@ -8,7 +7,8 @@ window.PageFirstInstall = {
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
passwordRepeat: '',
firstInstallToken: ''
}
}
},
@@ -18,20 +18,25 @@ window.PageFirstInstall = {
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
setPassword() {
LNbits.api
.request('PUT', `/api/v1/auth/first_install`, null, {
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
password_repeat: this.loginData.passwordRepeat,
first_install_token: this.loginData.firstInstallToken
})
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
.then(async () => {
const res = await LNbits.api.getAuthUser()
this.g.user = LNbits.map.user(res.data)
this.g.isPublicPage = false
this.$router.push('/admin')
})
.catch(this.utils.notifyApiError)
}
},
created() {
document.title = 'First Install - LNbits'
const params = new URLSearchParams(window.location.search)
this.loginData.firstInstallToken = params.get('token') || ''
}
}
+3 -29
View File
@@ -1,6 +1,5 @@
window.PageHome = {
template: '#page-home',
mixins: [window.windowMixin],
data() {
return {
lnurl: '',
@@ -20,40 +19,15 @@ window.PageHome = {
showClaimLnurl() {
return (
this.lnurl !== '' &&
this.allowRegister &&
'user-id-only' in this.LNBITS_AUTH_METHODS
this.g.settings.allowRegister &&
'user-id-only' in this.g.settings.authMethods
)
},
formatDescription() {
return LNbits.utils.convertMarkdown(this.SITE_DESCRIPTION)
return LNbits.utils.convertMarkdown(this.g.settings.siteDescription)
},
isAccessTokenExpired() {
return this.$q.cookies.get('is_access_token_expired')
},
allowRegister() {
return this.LNBITS_NEW_ACCOUNTS_ALLOWED
},
// TODO: this makes no sense
hasCustomImage() {
return this.LNBITS_CUSTOM_IMAGE
},
showHomepageElements() {
return this.LNBITS_SHOW_HOME_PAGE_ELEMENTS === true
},
siteTitle() {
return this.SITE_TITLE || ''
},
siteTagline() {
return this.SITE_TAGLINE || ''
},
adsEnabled() {
return this.AD_SPACE_ENABLED && this.AD_SPACE && this.AD_SPACE.length > 0
},
adsTitle() {
return this.AD_SPACE_TITLE || ''
},
ads() {
return this.AD_SPACE.map(ad => ad.split(';'))
}
},
methods: {
-1
View File
@@ -1,6 +1,5 @@
window.PageNodePublic = {
template: '#page-node-public',
mixins: [window.windowMixin],
data() {
return {
enabled: false,
-1
View File
@@ -1,5 +1,4 @@
window.PageNode = {
mixins: [window.windowMixin],
template: '#page-node',
config: {
globalProperties: {
-1
View File
@@ -1,6 +1,5 @@
window.PagePayments = {
template: '#page-payments',
mixins: [window.windowMixin],
data() {
return {
payments: [],
+14 -1
View File
@@ -1,6 +1,5 @@
window.PageUsers = {
template: '#page-users',
mixins: [window.windowMixin],
data() {
return {
paymentsWallet: {},
@@ -439,6 +438,20 @@ window.PageUsers = {
this.activeUser.show = false
}
},
async impersonateUser(user_id) {
try {
await LNbits.api.impersonateUser(user_id)
LNbits.utils.backupLocalStorage('impersonation', true)
this.$q.localStorage.setItem('lnbits.disclaimerShown', true)
window.location = '/wallet'
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'warning',
message: 'Failed to impersonate user!'
})
}
},
async showWalletPayments(walletId) {
this.activeUser.show = false
await this.fetchWallets(this.users[0].id)
-1
View File
@@ -1,6 +1,5 @@
window.PageWallet = {
template: '#page-wallet',
mixins: [window.windowMixin],
data() {
return {
parse: {
-1
View File
@@ -1,6 +1,5 @@
window.PageWallets = {
template: '#page-wallets',
mixins: [window.windowMixin],
data() {
return {
user: null,
+53 -12
View File
@@ -1,7 +1,7 @@
window._lnbitsUtils = {
url_for(url) {
const _url = new URL(url, window.location.origin)
_url.searchParams.set('v', WINDOW_SETTINGS.CACHE_KEY)
_url.searchParams.set('v', window.g.settings.cacheKey)
return _url.toString()
},
loadScript(src) {
@@ -67,6 +67,29 @@ window._lnbitsUtils = {
}
})
},
backupLocalStorage(backupKey, cleanup = false) {
const lnbitsEntries =
Object.entries(Quasar.LocalStorage.getAll()).filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
) || []
Quasar.LocalStorage.setItem(`lnbits.${backupKey}`, lnbitsEntries)
if (cleanup) {
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
}
},
restoreLocalStorage(backupKey) {
Object.entries(Quasar.LocalStorage.getAll())
.filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
)
.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
const lnbitsEntries =
Quasar.LocalStorage.getItem(`lnbits.${backupKey}`) || []
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.setItem(k, v))
Quasar.LocalStorage.remove(`lnbits.${backupKey}`)
},
async digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
@@ -114,6 +137,13 @@ window._lnbitsUtils = {
formatMsat(value) {
return this.formatSat(value / 1000)
},
parseJSONSafe(str) {
try {
return JSON.parse(str)
} catch (e) {
return null
}
},
notifyApiError(error) {
if (!error.response) {
return console.error(error)
@@ -123,17 +153,28 @@ window._lnbitsUtils = {
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
})
let messages = error.response.data.detail
if (messages) {
messages = Array.isArray(messages)
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
: (messages = [messages])
} else {
messages = [error.response.data.message || error.response.data.detail]
}
messages.forEach(message =>
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
.toUpperCase() || null,
icon: null,
closeBtn: true
})
)
},
search(data, q, field, separator) {
try {
+2
View File
@@ -45,6 +45,8 @@
"js/pages/error.js",
"js/pages/home.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder_preview.js",
"js/pages/extensions.js",
"js/pages/first-install.js",
"js/pages/payments.js",
+5 -1
View File
@@ -178,7 +178,11 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
status = await payment.check_status(skip_internal_payment_notifications=True)
from lnbits.core.services.payments import check_payment_status
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
+4 -4
View File
@@ -70,10 +70,10 @@
</div>
<script type="text/javascript">
const RENDERED_ROUTE = '{{ normalize_path(request.path) }}'
const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }}
Object.keys(WINDOW_SETTINGS).forEach(key => {
window[key] = WINDOW_SETTINGS[key]
})
const SETTINGS = {{ SETTINGS | tojson | safe }}
const CURRENCIES = {{ CURRENCIES | tojson | safe }}
// backwards compatibility for extensions (tpos)
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson | safe }}
</script>
<!-- vue templates -->
{% include('components.vue') %} {% include('pages.vue') %}
+36 -15
View File
@@ -36,7 +36,7 @@ include('components/lnbits-error.vue') %}
<q-list v-if="g.user" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('manage')"></q-item-label>
<div v-if="g.user.admin">
<q-item v-if="showAdmin" to="/admin">
<q-item v-if="g.settings.showAdmin" to="/admin">
<q-item-section side>
<q-icon
name="settings"
@@ -51,7 +51,7 @@ include('components/lnbits-error.vue') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="showNode" to="/node">
<q-item v-if="g.settings.showNodemanager" to="/node">
<q-item-section side>
<q-icon
name="developer_board"
@@ -66,7 +66,7 @@ include('components/lnbits-error.vue') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="showUsers" to="/users">
<q-item v-if="g.settings.showAdmin" to="/users">
<q-item-section side>
<q-icon
name="groups"
@@ -81,7 +81,7 @@ include('components/lnbits-error.vue') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="showAudit" to="/audit">
<q-item v-if="g.settings.showAudit" to="/audit">
<q-item-section side>
<q-icon
name="playlist_add_check_circle"
@@ -112,7 +112,7 @@ include('components/lnbits-error.vue') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="showExtensions" to="/extensions">
<q-item v-if="g.settings.showExtensions" to="/extensions">
<q-item-section side>
<q-icon
name="extension"
@@ -561,11 +561,32 @@ include('components/lnbits-error.vue') %}
</template>
<template id="lnbits-extension-rating">
<div style="margin-bottom: 3px">
<q-rating v-model="rating" size="1.5em" :max="5" color="primary"
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
<div class="row items-center q-gutter-xs" style="margin-bottom: 3px">
<q-rating
:model-value="displayRating"
size="1.4em"
:max="5"
color="primary"
icon="star_border"
icon-selected="star"
icon-half="star_half"
readonly
:class="clickable ? 'cursor-pointer' : ''"
@click="handleClick"
>
<q-tooltip v-if="!hasData">
<span v-text="$t('extension_rating_soon')"></span>
</q-tooltip>
<q-tooltip v-else-if="clickable">
<span v-text="$t('reviews_open')"></span>
</q-tooltip>
</q-rating>
<div
class="text-caption text-grey"
v-if="count !== null && count !== undefined"
>
<span v-text="`(${count})`"></span>
</div>
</div>
</template>
@@ -665,7 +686,7 @@ include('components/lnbits-error.vue') %}
dense
filled
v-model="walletName"
:label="$t('name_your_wallet', {name: SITE_TITLE + ' *'})"
:label="$t('name_your_wallet', {name: g.settings.siteTitle + ' *'})"
></q-input>
<q-card-actions vertical align="center" class="q-pa-none">
<q-btn
@@ -939,9 +960,9 @@ include('components/lnbits-error.vue') %}
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="
keycloakIcon
? keycloakIcon
: 'lnbits/static/images/keycloak-logo.png'
g.settings.keycloakIcon
? g.settings.keycloakIcon
: utils.url_for('lnbits/static/images/keycloak-logo.png')
"
></q-img>
</q-avatar>
@@ -949,7 +970,7 @@ include('components/lnbits-error.vue') %}
<span
v-text="
$t('signin_with_custom_org', {
custom_org: keycloakOrg
custom_org: g.settings.keycloakOrg || 'Keycloak'
})
"
></span>
@@ -137,6 +137,19 @@
:hint="$t('extension_builder_manifest_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('reviews_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_reviews_url"
:label="$t('reviews_url_label')"
:hint="$t('reviews_url_hint')"
type="url"
autocomplete="off"
></q-input>
</div>
</div>
</div>
</q-card-section>
@@ -50,7 +50,7 @@
<br />
</div>
<div class="col">
<div v-if="LNBITS_NODE_UI">
<div v-if="g.settings.hasNodemanager">
<p>
<span v-text="$t('node_management')"></span>
</p>
@@ -70,7 +70,7 @@
v-model="formData.lnbits_node_ui_transactions"
></q-toggle>
</div>
<p v-if="!LNBITS_NODE_UI">
<p v-if="!g.settings.hasNodemanager">
<span v-text="$t('node_management_not_supported')"></span>
</p>
</div>
@@ -86,6 +86,42 @@
></q-input>
</div>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p>
<span v-text="$t('wallet_featured_button_label')"></span>
</p>
<q-input
filled
type="text"
v-model="formData.lnbits_wallet_featured_button_label"
label="Loop to Onchain"
:hint="$t('wallet_featured_button_label_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('wallet_featured_button_url')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_wallet_featured_button_url"
label="/boltz"
:hint="$t('wallet_featured_button_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('wallet_featured_button_icon')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_wallet_featured_button_icon"
label="bolt"
:hint="$t('wallet_featured_button_icon_hint')"
></q-input>
</div>
</div>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_badge')"></span></p>
@@ -1,6 +1,6 @@
<template id="lnbits-footer">
<q-footer
v-if="showFooter"
v-if="g.settings.showHomePageElements"
class="bg-transparent q-px-lg q-py-md"
:class="{'text-dark': !$q.dark.isActive}"
>
@@ -9,7 +9,7 @@
<q-toolbar-title class="text-caption">
<span v-text="title"></span>
<br />
<small v-text="$t('lnbits_version') + ': ' + version"></small>
<small v-text="version"></small>
</q-toolbar-title>
<q-space></q-space>
<q-btn
+37 -15
View File
@@ -1,6 +1,10 @@
<template id="lnbits-header">
<q-header bordered class="bg-marginal-bg">
<q-banner v-if="showVoidwallet" class="bg-warning text-white" dense>
<q-banner
v-if="g.settings.showVoidwallet"
class="bg-warning text-white"
dense
>
<template v-slot:avatar>
<q-icon name="warning" color="white" />
</template>
@@ -24,13 +28,15 @@
<q-toolbar-title>
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
<q-img
v-if="customLogoUrl"
v-if="customLogo"
height="30px"
alt="Logo"
:src="customLogoUrl"
:src="customLogo"
></q-img>
<span v-else-if="!titleIsLnbits"><strong>LN</strong>bits</span>
<span v-else v-text="title"></span>
<span v-else-if="g.settings.siteTitle == 'LNbits'"
><strong>LN</strong>bits</span
>
<span v-else v-text="g.settings.siteTitle"></span>
</q-btn>
<q-badge v-if="g.user && g.user.super_user">Super User</q-badge>
<q-badge v-else-if="g.user && g.user.admin">Admin User</q-badge>
@@ -38,31 +44,31 @@
<q-badge
class="q-mr-md"
v-show="$q.screen.gt.sm"
v-if="hasCustomBadge"
:label="customBadge"
:color="customBadgeColor"
v-if="g.settings.customBadge"
:label="g.settings.customBadge"
:color="g.settings.customBadgeColor"
>
</q-badge>
<q-badge
v-show="$q.screen.gt.sm"
v-if="hasServiceFee"
v-if="g.user && g.settings.serviceFee > 0"
color="green"
class="q-mr-md"
>
<span
v-if="hasServiceFeeMax"
v-if="g.user && g.settings.serviceFeeMax > 0"
v-text="
$t('service_fee_max_badge', {
amount: serviceFee,
max: serviceFeeMax,
amount: g.settings.serviceFee,
max: g.settings.serviceFeeMax,
denom: g.denomination
})
"
></span>
<span
v-else
v-text="$t('service_fee_badge', {amount: serviceFee})"
v-text="$t('service_fee_badge', {amount: g.settings.serviceFee})"
></span>
<q-tooltip><span v-text="$t('service_fee_tooltip')"></span></q-tooltip>
</q-badge>
@@ -71,9 +77,11 @@
<span>OFFLINE</span>
</q-badge>
<lnbits-language-dropdown></lnbits-language-dropdown>
<lnbits-language-dropdown
@language-changed="handleLanguageChanged({locale: $event})"
></lnbits-language-dropdown>
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
<q-btn-dropdown v-if="g.user" flat rounded size="md" class="q-pl-sm">
<template v-slot:label>
<q-avatar
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
@@ -136,6 +144,20 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="g.isUserImpersonated"
@click="stopImpersonation"
rounded
size="sm"
class="q-pl-sm"
color="negative"
icon="face_retouching_off"
label="Stop"
>
<q-tooltip
><span v-text="$t('stop_user_impersonation')"></span
></q-tooltip>
</q-btn>
</q-toolbar>
</q-header>
</template>
@@ -1,5 +1,11 @@
<template id="lnbits-language-dropdown">
<q-btn-dropdown dense flat rounded size="sm" icon="language" class="q-pl-md">
<q-btn-dropdown dense flat rounded size="md" class="q-pl-md">
<template v-slot:label>
<q-item-section>
<q-item-label v-text="currentLanguage.display"></q-item-label>
<q-tooltip><span v-text="currentLanguage.label"></span></q-tooltip>
</q-item-section>
</template>
<q-list v-for="(lang, index) in langs" :key="index">
<q-item
clickable
@@ -118,7 +118,7 @@
</q-list>
</q-card-section>
<q-expansion-item
v-if="!HIDE_API"
v-if="!g.settings.hideApi"
group="api"
dense
expand-separator
@@ -152,7 +152,7 @@
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
v-if="!g.settings.hideApi"
group="api"
dense
expand-separator
@@ -197,7 +197,7 @@
</q-card>
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
v-if="!g.settings.hideApi"
group="api"
dense
expand-separator
@@ -244,7 +244,7 @@
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
v-if="!g.settings.hideApi"
group="api"
dense
expand-separator
@@ -273,7 +273,7 @@
</q-card>
</q-expansion-item>
<q-expansion-item
v-if="!HIDE_API"
v-if="!g.settings.hideApi"
group="api"
dense
expand-separator
@@ -83,9 +83,7 @@
class="text-center"
v-text="$t('export_to_phone_desc')"
></p>
<lnbits-qrcode
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
></lnbits-qrcode>
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
</q-card-section>
</q-card>
</q-expansion-item>
+64 -31
View File
@@ -120,7 +120,7 @@
class="q-mb-md"
></q-input>
<q-input
v-if="g.user.has_password"
v-if="g.user.hasPassword"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
@@ -258,15 +258,20 @@
class="q-mb-md"
>
</q-input>
<div v-if="!g.user.email" class="row"></div>
<div v-if="!g.user.email" class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS or
"github-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm text-h6">
<div
v-if="
'google-auth' in g.settings.authMethods ||
'github-auth' in g.settings.authMethods
"
class="col q-pa-sm text-h6"
>
<span v-text="$t('verify_email')"></span>:
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm">
<div
v-if="'google-auth' in g.settings.authMethods"
class="col q-pa-sm"
>
<q-btn
:href="`/api/v1/auth/google?user_id=${g.user.id}`"
type="a"
@@ -284,8 +289,10 @@
<div>Google</div>
</q-btn>
</div>
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm">
<div
v-if="'github-auth' in g.settings.authMethods"
class="col q-pa-sm"
>
<q-btn
:href="`/api/v1/auth/github?user_id=${g.user.id}`"
type="a"
@@ -303,7 +310,6 @@
<div>GitHub</div>
</q-btn>
</div>
{%endif%}
</div>
</q-card-section>
@@ -333,6 +339,16 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="g.user.extra.visible_wallet_count"
:label="$t('visible_wallet_count')"
filled
dense
type="number"
class="q-mb-md"
></q-input>
<q-input
v-model="g.user.external_id"
:label="$t('external_id')"
@@ -380,22 +396,11 @@
<span v-text="$t('language')"></span>
</div>
<div class="col-8">
<lnbits-language-dropdown />
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('visible_wallet_count')"></span>
</div>
<div class="col-8">
<q-input
v-model="g.user.extra.visible_wallet_count"
:label="$t('visible_wallet_count')"
filled
dense
type="number"
class="q-mb-md"
></q-input>
<lnbits-language-dropdown
@language-changed="
siteCustomisationChanged({locale: $event})
"
/>
</div>
</div>
@@ -407,7 +412,9 @@
<q-btn
v-for="theme in themeOptions"
:key="theme.name"
@click="g.themeChoice = theme.name"
@click="
siteCustomisationChanged({themeChoice: theme.name})
"
:color="theme.color"
dense
flat
@@ -427,6 +434,9 @@
<q-input
v-model="g.bgimageChoice"
:label="$t('background_image')"
@update:model-value="
siteCustomisationChanged({bgimageChoice: $event})
"
>
<q-tooltip
><span v-text="$t('background_image')"></span
@@ -445,6 +455,9 @@
round
icon="gradient"
v-model="g.gradientChoice"
@update:model-value="
siteCustomisationChanged({gradientChoice: $event})
"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
@@ -463,6 +476,9 @@
flat
round
v-model="g.darkChoice"
@update:model-value="
siteCustomisationChanged({darkChoice: $event})
"
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
size="sm"
>
@@ -481,6 +497,9 @@
v-model="g.borderChoice"
:options="borderOptions"
label="Borders"
@update:model-value="
siteCustomisationChanged({borderChoice: $event})
"
>
<q-tooltip
><span v-text="$t('border_choices')"></span
@@ -495,7 +514,7 @@
<div class="col-8">
<lnbits-notifications-btn
v-if="g.user"
pubkey="{{ WEBPUSH_PUBKEY }}"
pubkey="g.settings.webpushPubkey"
></lnbits-notifications-btn>
</div>
</div>
@@ -508,6 +527,9 @@
v-model="g.reactionChoice"
:options="reactionOptions"
label="Reactions"
@update:model-value="
siteCustomisationChanged({reactionChoice: $event})
"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
@@ -515,6 +537,17 @@
</q-select>
</div>
</div>
<q-card-section>
<q-btn
@click="
siteCustomisationChanged(defaultSiteCustomisation)
"
:label="$t('reset_defaults')"
filled
color="primary"
class="float-right q-mb-md"
></q-btn>
</q-card-section>
</q-tab-panel>
<q-tab-panel name="notifications">
<q-card-section>
@@ -525,7 +558,7 @@
></span>
<br />
<q-badge
v-if="!LNBITS_NOSTR_CONFIGURED"
v-if="!g.settings.nostrConfigured"
v-text="$t('not_connected')"
></q-badge>
</div>
@@ -545,7 +578,7 @@
<span v-text="$t('notifications_chat_id')"></span>
<br />
<q-badge
v-if="!LNBITS_TELEGRAM_CONFIGURED"
v-if="!g.settings.telegramConfigured"
v-text="$t('not_connected')"
></q-badge>
</div>
@@ -941,7 +974,7 @@
v-if="props.row.thumbnail_base64"
target="_blank"
style="color: inherit"
:href="`/api/v1/assets/${props.row.id}/binary`"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-img
:src="
+234 -3
View File
@@ -15,6 +15,7 @@
<q-space></q-space>
<q-input
v-if="$q.screen.gt.sm"
:label="$t('search_extensions')"
:dense="dense"
class="float-right q-pr-xl"
@@ -33,6 +34,14 @@
</q-icon>
</template>
</q-input>
<q-btn
v-else
flat
icon="search"
@click="searchToggle = !searchToggle"
class="q-mr-md"
>
</q-btn>
<q-badge
v-if="g.user.admin && updatableExtensions?.length"
@click="showUpdateAllDialog = true"
@@ -68,6 +77,25 @@
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
</q-tabs>
<div v-if="$q.screen.lt.sm && searchToggle" class="q-pa-sm">
<q-input
:label="$t('search_extensions')"
dense
class=""
v-model="searchTerm"
autofocus
>
<template v-slot:append>
<q-icon
v-if="searchTerm !== ''"
name="close"
@click="searchTerm = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
@@ -135,7 +163,22 @@
v-text="extension.name"
></div>
<div style="justify-content: space-between; display: flex">
<lnbits-extension-rating :rating="0" />
<lnbits-extension-rating
:rating="
formatAvg(
extension.reviewStats
? extension.reviewStats.avg_rating
: 0
)
"
:count="
extension.reviewStats
? extension.reviewStats.review_count
: null
"
:clickable="!!reviewsUrl"
@click="openReviews(extension)"
/>
<q-btn-group size="xs" style="margin: 5px 0">
<q-btn
v-if="extension.hasFreeRelease"
@@ -970,8 +1013,20 @@
</div>
<div class="col-sm-12 col-md-4 q-pl-sm">
<lnbits-extension-rating
:rating="0"
size="2.5em"
:rating="
formatAvg(
selectedExtension && selectedExtension.reviewStats
? selectedExtension.reviewStats.avg_rating
: 0
)
"
:count="
selectedExtension && selectedExtension.reviewStats
? selectedExtension.reviewStats.review_count
: null
"
:clickable="!!reviewsUrl"
@click="openReviews(selectedExtension)"
></lnbits-extension-rating>
<div class="q-mt-md">
<b>
@@ -1029,6 +1084,182 @@
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="reviewsDialog.show" position="top">
<q-card
class="q-pa-md lnbits__dialog-card"
style="width: 900px; max-width: 95vw"
>
<q-card-section class="q-gutter-y-sm">
<div class="row items-center">
<div class="col">
<div
class="text-h6"
v-text="
(reviewsDialog.extension && reviewsDialog.extension.name) ||
(reviewsDialog.extension && reviewsDialog.extension.id)
"
></div>
<div
class="text-caption text-grey"
v-text="reviewsDialog.extension && reviewsDialog.extension.id"
></div>
</div>
<div class="col-auto">
<lnbits-extension-rating
:rating="
formatAvg(
reviewsDialog.extension && reviewsDialog.extension.reviewStats
? reviewsDialog.extension.reviewStats.avg_rating
: 0
)
"
:count="
reviewsDialog.extension && reviewsDialog.extension.reviewStats
? reviewsDialog.extension.reviewStats.review_count
: null
"
></lnbits-extension-rating>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="!reviewsUrl">
<div
class="text-negative"
v-text="$t('reviews_url_not_configured')"
></div>
</q-card-section>
<q-card-section v-else>
<div v-if="reviewsDialog.loading" class="row justify-center q-pa-lg">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
</div>
<div v-else>
<div v-if="reviewsDialog.error" class="text-negative q-mb-md">
<span v-text="reviewsDialog.error"></span>
</div>
<q-form @submit="submitReview" class="q-gutter-md">
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-6">
<q-input
dense
filled
v-model="reviewsDialog.form.name"
:label="$t('reviews_name')"
></q-input>
</div>
<div class="col-12 col-md-6">
<q-rating
v-model="reviewsDialog.form.rating"
max="10"
icon="star_border"
icon-selected="star"
icon-half="star_half"
color="primary"
class="float-right"
></q-rating>
</div>
<div class="col-12 col-md-6">
<q-input
dense
filled
type="textarea"
autogrow
v-model="reviewsDialog.form.comment"
:label="$t('reviews_comment')"
></q-input>
</div>
<div class="col-12 col-md-6">
<q-btn
type="submit"
color="primary"
class="float-right"
unelevated
:loading="reviewsDialog.submitting"
:disable="!reviewsDialog.form.rating"
:label="$t('reviews_submit')"
></q-btn>
</div>
</div>
</q-form>
<q-separator class="q-my-md"></q-separator>
<div class="row q-col-gutter-lg">
<div class="col-12">
<div v-if="reviews.length === 0" class="text-grey q-mb-md">
<span v-text="$t('no_reviews')"></span>
</div>
<div v-else>
<q-table
:rows="reviews"
:columns="reviewsTable.columns"
v-model:pagination="reviewsTable.pagination"
@request="getTagReviews"
:loading="reviewsTable.loading"
row-key="name"
:filter="reviewsTable.search"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="name" :props="props">
<span v-text="props.row.name"></span>
</q-td>
<q-td key="comment" :props="props">
<span v-text="props.row.comment"></span>
</q-td>
<q-td key="created_at" :props="props">
<span
v-text="formatReviewDate(props.row.created_at)"
></span>
</q-td>
<q-td key="rating" :props="props">
<q-rating
class="float-right q-pt-xs"
readonly
icon="star_border"
icon-selected="star"
icon-half="star_half"
:model-value="formatAvg(props.row.rating)"
size="sm"
color="secondary"
></q-rating>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</div>
</div>
</q-card-section>
<q-separator v-if="reviews.length"></q-separator>
<q-card-section>
<q-btn
@click="reviewsDialog.show = false"
color="grey"
outline
class="float-right"
:label="$t('close')"
></q-btn>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="paymentDialog.show" position="top">
<q-card class="q-pa-md lnbits__dialog-card">
<q-card-section>
<q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
<lnbits-qrcode
:value="paymentDialog.invoice"
:options="{width: 800}"
class="rounded-borders"
></lnbits-qrcode>
</q-responsive>
</q-card-section>
<q-card-actions align="between">
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showUpdateAllDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="row">
+12
View File
@@ -53,6 +53,18 @@
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.firstInstallToken"
:type="loginData.isPwd ? 'password' : 'text'"
:label="$t('first_install_token')"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
+28 -15
View File
@@ -2,14 +2,14 @@
<div class="home row justify-center items-center">
<div
class="full-width content-center"
:style="`max-width: ${hasCustomImage ? '850' : '600'}px; min-height: 55vh;`"
:style="`max-width: ${g.settings.customImage ? '850' : '600'}px; min-height: 55vh;`"
>
<div v-if="showHomepageElements" class="row q-mb-md">
<div v-if="g.settings.showHomepageElements" class="row q-mb-md">
<div class="col-12">
<div>
<h5 v-text="siteTitle" class="q-my-none"></h5>
<h5 v-text="g.settings.siteTitle" class="q-my-none"></h5>
<template v-if="$q.screen.gt.sm">
<h6 class="q-my-sm" v-text="siteTagline"></h6>
<h6 class="q-my-sm" v-text="g.settings.siteTagline"></h6>
<p class="q-my-sm" v-html="formatDescription"></p>
</template>
</div>
@@ -31,7 +31,10 @@
<div class="row">
<div
class="col-12"
:class="{'col-sm-7': hasCustomImage, 'col-lg-6': hasCustomImage}"
:class="{
'col-sm-7': g.settings.customImage,
'col-lg-6': g.settings.customImage
}"
>
<div v-if="showClaimLnurl">
<q-card-section>
@@ -54,8 +57,8 @@
<div v-else>
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowRegister"
:auth-methods="LNBITS_AUTH_METHODS"
:allowed_new_users="g.settings.allowRegister"
:auth-methods="g.settings.authMethods"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
@@ -70,7 +73,7 @@
v-if="authAction !== 'reset'"
>
<p
v-if="authAction === 'login' && allowRegister"
v-if="authAction === 'login' && g.settings.allowRegister"
class="q-mb-none"
>
Not registered?
@@ -82,7 +85,9 @@
>
</p>
<p
v-else-if="authAction === 'login' && !allowRegister"
v-else-if="
authAction === 'login' && !g.settings.allowRegister
"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
@@ -101,7 +106,7 @@
</username-password>
<user-id-only
v-if="authMethod == 'user-id-only'"
:allowed_new_users="allowRegister"
:allowed_new_users="g.settings.allowRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
@@ -114,16 +119,20 @@
</user-id-only>
</div>
</div>
<div v-if="hasCustomImage" class="col-sm-5 col-lg-6 gt-xs">
<div v-if="g.settings.customImage" class="col-sm-5 col-lg-6 gt-xs">
<div class="full-height flex flex-center q-pa-lg">
<q-img :src="hasCustomImage" :ratio="1" width="250px"></q-img>
<q-img
:src="g.settings.customImage"
:ratio="1"
width="250px"
></q-img>
</div>
</div>
</div>
</q-card>
</div>
<div v-if="showHomepageElements">
<div v-if="g.settings.showHomepageElements">
<div class="flex flex-center q-gutter-md q-py-md">
<q-btn
outline
@@ -148,10 +157,14 @@
</div>
<div
v-if="adsEnabled"
v-if="g.settings.showAdSpace"
class="justify-center col-10 q-my-lg row q-col-gutter-sm"
>
<a :href="ad[0]" v-for="ad in g.ads" class="lnbits-ad col-12 col-md-4">
<a
:href="ad[0]"
v-for="ad in g.settings.adSpace"
class="lnbits-ad col-12 col-md-4"
>
<q-img v-if="$q.dark.isActive" :src="ad[1]"></q-img>
<q-img v-else :src="ad[2]"></q-img>
</a>

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