Compare commits

...
Author SHA1 Message Date
Arc 962a09bb30 fundle 2026-01-29 18:21:05 +00:00
Arc 92c86eb837 make 2026-01-29 18:17:07 +00:00
Arc dadff18a9c feat: route whitelist/blacklist
security feature middleware to block/allow routes
2026-01-29 18:11:28 +00:00
Arc 87c1684a63 init 2026-01-29 17:45:44 +00: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
31 changed files with 480 additions and 77 deletions
+11 -7
View File
@@ -44,15 +44,19 @@ jobs:
if: github.repository == 'lnbits/lnbits' if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: 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 - uses: actions/checkout@v4
- name: Build and publish to pypi - name: Set up Python 3.10
uses: JRubics/poetry-publish@v1.15 uses: actions/setup-python@v5
with: 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: appimage:
needs: [ release ] needs: [ release ]
+11 -7
View File
@@ -56,15 +56,19 @@ jobs:
if: github.repository == 'lnbits/lnbits' if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: 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 - uses: actions/checkout@v4
- name: Build and publish to pypi - name: Set up Python 3.10
uses: JRubics/poetry-publish@v1.15 uses: actions/setup-python@v5
with: 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: appimage:
needs: [ release ] needs: [ release ]
+2
View File
@@ -66,6 +66,7 @@ from .middleware import (
add_first_install_middleware, add_first_install_middleware,
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
add_route_access_middleware,
) )
from .tasks import internal_invoice_listener, invoice_listener, run_interval from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -192,6 +193,7 @@ def create_app() -> FastAPI:
# adds security middleware # adds security middleware
add_ip_block_middleware(app) add_ip_block_middleware(app)
add_route_access_middleware(app)
add_ratelimit_middleware(app) add_ratelimit_middleware(app)
register_exception_handlers(app) register_exception_handlers(app)
+1
View File
@@ -204,6 +204,7 @@ async def get_user_from_account(
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers, fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
) )
+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}"); 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")
+2
View File
@@ -187,6 +187,7 @@ class Account(AccountId):
pubkey: str | None = None pubkey: str | None = None
email: str | None = None email: str | None = None
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -288,6 +289,7 @@ class User(BaseModel):
fiat_providers: list[str] = [] fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property @property
def wallet_ids(self) -> list[str]: def wallet_ids(self) -> list[str]:
+17 -1
View File
@@ -467,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email) return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update") @auth_router.patch("")
async def update( async def update(
data: UpdateUser, account: Account = Depends(check_account_exists) data: UpdateUser, account: Account = Depends(check_account_exists)
) -> User | None: ) -> User | None:
@@ -483,6 +483,22 @@ async def update(
return await get_user_from_account(account) 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") @auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install: if not settings.first_install:
+3
View File
@@ -98,6 +98,9 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet, "LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements, "LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options, "LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"WALLET_FEATURED_BUTTON_LABEL": settings.lnbits_wallet_featured_button_label,
"WALLET_FEATURED_BUTTON_URL": settings.lnbits_wallet_featured_button_url,
"WALLET_FEATURED_BUTTON_ICON": settings.lnbits_wallet_featured_button_icon,
"LNBITS_VERSION": settings.version, "LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo, "USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction, "LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
+84
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
@@ -18,6 +19,53 @@ from lnbits.core.models import AuditEntry
from lnbits.helpers import normalize_path, template_renderer from lnbits.helpers import normalize_path, template_renderer
from lnbits.settings import settings from lnbits.settings import settings
_LOCALHOST_IPS = {"127.0.0.1", "::1"}
_PUBLIC_ASSET_PATHS = {
"/favicon.ico",
"/service-worker.js",
}
def _normalize_match_path(path: str) -> str:
if not path:
return "/"
if path != "/" and path.endswith("/"):
return path.rstrip("/")
return path
def _route_pattern_matches(pattern: str, path: str) -> bool:
if not pattern:
return False
if not pattern.startswith("/"):
pattern = f"/{pattern}"
pattern = _normalize_match_path(pattern)
path = _normalize_match_path(path)
if pattern.endswith("*"):
prefix = pattern.rstrip("*")
return path.startswith(prefix)
if pattern == path:
return True
escaped = re.escape(pattern)
escaped = re.sub(r"\\\{[^/]+\\\}", r"[^/]+", escaped)
return re.fullmatch(escaped, path) is not None
def _response_by_accepted_type(request: Request, msg: str, status_code: HTTPStatus):
accept_header = request.headers.get("accept", "")
if "text/html" in accept_header.split(","):
return HTMLResponse(
status_code=status_code,
content=template_renderer()
.TemplateResponse(
request,
"error.html",
{"err": msg, "status_code": status_code, "message": msg},
)
.body,
)
return JSONResponse(status_code=status_code, content={"detail": msg})
class InstalledExtensionMiddleware: class InstalledExtensionMiddleware:
# This middleware class intercepts calls made to the extensions API and: # This middleware class intercepts calls made to the extensions API and:
@@ -235,6 +283,42 @@ def add_ip_block_middleware(app: FastAPI):
return await call_next(request) return await call_next(request)
def add_route_access_middleware(app: FastAPI):
@app.middleware("http")
async def route_access_middleware(request: Request, call_next):
if not settings.lnbits_route_access_control_enabled:
return await call_next(request)
if not request.client:
return JSONResponse(
status_code=HTTPStatus.FORBIDDEN,
content={"detail": "No request client"},
)
if request.client.host in _LOCALHOST_IPS:
return await call_next(request)
path = request.url.path or "/"
if "/static/" in path or path in _PUBLIC_ASSET_PATHS:
return await call_next(request)
whitelist = settings.lnbits_route_access_whitelist
blacklist = settings.lnbits_route_access_blacklist
if whitelist:
if any(_route_pattern_matches(route, path) for route in whitelist):
return await call_next(request)
return _response_by_accepted_type(
request, f"Route not whitelisted: {path}", HTTPStatus.FORBIDDEN
)
if blacklist and any(
_route_pattern_matches(route, path) for route in blacklist
):
return _response_by_accepted_type(
request, f"Route is blacklisted: {path}", HTTPStatus.FORBIDDEN
)
return await call_next(request)
def add_first_install_middleware(app: FastAPI): def add_first_install_middleware(app: FastAPI):
@app.middleware("http") @app.middleware("http")
async def first_install_middleware(request: Request, call_next): async def first_install_middleware(request: Request, call_next):
+8
View File
@@ -252,6 +252,11 @@ class ThemesSettings(LNbitsSettings):
) )
lnbits_show_home_page_elements: bool = Field(default=True) lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet") 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: str | None = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning") lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field( lnbits_theme_options: list[str] = Field(
@@ -416,6 +421,9 @@ class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_unit: str = Field(default="minute") lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: list[str] = Field(default=[]) lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: list[str] = Field(default=[]) lnbits_blocked_ips: list[str] = Field(default=[])
lnbits_route_access_control_enabled: bool = Field(default=False)
lnbits_route_access_whitelist: list[str] = Field(default=[])
lnbits_route_access_blacklist: list[str] = Field(default=[])
lnbits_callback_url_rules: list[str] = Field( lnbits_callback_url_rules: list[str] = Field(
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"] default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
) )
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
+21
View File
@@ -363,6 +363,18 @@ window.localisation.en = {
watchdog: 'Watchdog', watchdog: 'Watchdog',
server_logs: 'Server Logs', server_logs: 'Server Logs',
ip_blocker: 'IP Blocker', ip_blocker: 'IP Blocker',
route_access_control: 'Route Access Control',
route_access_control_enable: 'Enable route access control',
route_access_control_hint:
'When enabled, non-local requests are restricted. Requests from 127.0.0.1 (and ::1) are always allowed.',
route_access_whitelist_label: 'Route Whitelist',
route_access_whitelist_hint:
'Only allow non-local access to these routes (leave empty to disable whitelist mode).',
route_access_blacklist_label: 'Route Blacklist',
route_access_blacklist_hint:
'Block non-local access to these routes (ignored when a whitelist is set).',
route_access_save_confirm:
'Are you sure you want to save? This can impact access to LNbits if you are not accessing locally.',
security: 'Security', security: 'Security',
security_tools: 'Security tools', security_tools: 'Security tools',
block_access_hint: 'Block access by IP', block_access_hint: 'Block access by IP',
@@ -658,6 +670,15 @@ window.localisation.en = {
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML', ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
ui_default_wallet_name: 'Default Wallet Name', ui_default_wallet_name: 'Default Wallet Name',
ui_default_theme: 'Default Theme', 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', lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination', denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token', denomination_hint: 'The name for the FakeWallet token',
+3
View File
@@ -154,6 +154,9 @@ window._lnbitsApi = {
name: name name: name
}) })
}, },
updateUiCustomization(data = {}) {
return this.request('patch', '/api/v1/auth/ui', null, data)
},
resetWalletKeys(wallet) { resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then( return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => { res => {
+2 -1
View File
@@ -14,7 +14,8 @@ window.LNbits = {
fiat_providers: data.fiat_providers || [], fiat_providers: data.fiat_providers || [],
super_user: data.super_user, super_user: data.super_user,
extra: data.extra ?? {}, extra: data.extra ?? {},
hasPassword: data.has_password ?? false hasPassword: data.has_password ?? false,
uiCustomization: data.ui_customization || {}
} }
const mapWallet = this.wallet const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => { obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
@@ -8,11 +8,58 @@ window.app.component('lnbits-admin-security', {
serverlogEnabled: false, serverlogEnabled: false,
nostrAcceptedUrl: '', nostrAcceptedUrl: '',
formAllowedIPs: '', formAllowedIPs: '',
formCallbackUrlRule: '' formCallbackUrlRule: '',
routeOptions: [],
routeOptionsFiltered: [],
routeOptionsLoading: false
} }
}, },
created() {}, created() {
this.loadOpenApiRoutes()
},
methods: { methods: {
async loadOpenApiRoutes() {
this.routeOptionsLoading = true
try {
const response = await fetch('/openapi.json')
if (!response.ok) {
throw new Error('Failed to load OpenAPI spec')
}
const data = await response.json()
const paths = Object.keys(data.paths || {}).sort()
this.routeOptions = paths
this.routeOptionsFiltered = paths
} catch (error) {
console.warn(error)
} finally {
this.routeOptionsLoading = false
}
},
addRouteOption(value, done) {
const route = value.trim()
if (!route) {
done()
return
}
if (!this.routeOptions.includes(route)) {
this.routeOptions.push(route)
this.routeOptions.sort()
}
this.routeOptionsFiltered = this.routeOptions
done(route)
},
filterRouteOptions(val, update) {
update(() => {
if (!val) {
this.routeOptionsFiltered = this.routeOptions
return
}
const needle = val.toLowerCase()
this.routeOptionsFiltered = this.routeOptions.filter(route =>
route.toLowerCase().includes(needle)
)
})
},
addAllowedIPs() { addAllowedIPs() {
const allowedIPs = this.formAllowedIPs.trim() const allowedIPs = this.formAllowedIPs.trim()
const allowed_ips = this.formData.lnbits_allowed_ips const allowed_ips = this.formData.lnbits_allowed_ips
@@ -69,6 +69,17 @@ window.app.component('lnbits-header', {
window.location = '/users' window.location = '/users'
} catch (e) { } catch (e) {
console.warn(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) LNbits.utils.notifyApiError(e)
} }
} }
@@ -1,5 +1,16 @@
window.app.component('lnbits-language-dropdown', { window.app.component('lnbits-language-dropdown', {
template: '#lnbits-language-dropdown', template: '#lnbits-language-dropdown',
computed: {
currentLanguage() {
return (
this.langs.find(lang => lang.value === window.i18n.global.locale) || {
value: 'en',
label: 'English',
display: '🇬🇧 EN'
}
)
}
},
methods: { methods: {
activeLanguage(lang) { activeLanguage(lang) {
return window.i18n.global.locale === lang return window.i18n.global.locale === lang
@@ -8,6 +19,7 @@ window.app.component('lnbits-language-dropdown', {
this.g.locale = newValue this.g.locale = newValue
window.i18n.global.locale = newValue window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue) this.$q.localStorage.set('lnbits.lang', newValue)
this.$emit('language-changed', newValue)
} }
}, },
data() { data() {
@@ -9,6 +9,10 @@ window.app.component('lnbits-theme', {
'g.disclaimerShown'(val) { 'g.disclaimerShown'(val) {
this.$q.localStorage.setItem('lnbits.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) { 'g.isFiatPriority'(val) {
this.$q.localStorage.setItem('lnbits.isFiatPriority', val) this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
}, },
@@ -123,6 +127,13 @@ window.app.component('lnbits-theme', {
if (this.g.mobileSimple === true) { if (this.g.mobileSimple === true) {
document.body.classList.add('mobile-simple') 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() this.checkUrlParams()
} }
}) })
+32 -14
View File
@@ -39,6 +39,15 @@ window.PageAccount = {
color: 'pink-3' color: 'pink-3'
} }
], ],
defaultSiteCustomisation: {
locale: 'en',
themeChoice: 'salvador',
bgimageChoice: '',
gradientChoice: true,
darkChoice: true,
borderChoice: 'hard-border',
reactionChoice: 'confettiBothSides'
},
reactionOptions: [ reactionOptions: [
'None', 'None',
'confettiBothSides', 'confettiBothSides',
@@ -213,26 +222,18 @@ window.PageAccount = {
} }
}, },
methods: { methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
},
changeLanguage(newValue) { changeLanguage(newValue) {
window.i18n.global.locale = newValue window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue) this.$q.localStorage.set('lnbits.lang', newValue)
}, },
async updateAccount() { async updateAccount() {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request('PATCH', '/api/v1/auth', null, {
'PUT', user_id: this.g.user.id,
'/api/v1/auth/update', username: this.g.user.username,
null, email: this.g.user.email,
{ extra: this.g.user.extra
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.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
this.hasUsername = !!data.username this.hasUsername = !!data.username
Quasar.Notify.create({ Quasar.Notify.create({
@@ -680,6 +681,23 @@ window.PageAccount = {
l => l.name !== label.name 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)
}
} }
}, },
+22 -1
View File
@@ -8,7 +8,9 @@ window.PageAdmin = {
lnbits_exchange_rate_providers: [], lnbits_exchange_rate_providers: [],
lnbits_audit_exclude_paths: [], lnbits_audit_exclude_paths: [],
lnbits_audit_include_paths: [], lnbits_audit_include_paths: [],
lnbits_audit_http_response_codes: [] lnbits_audit_http_response_codes: [],
lnbits_route_access_whitelist: [],
lnbits_route_access_blacklist: []
}, },
isSuperUser: false, isSuperUser: false,
needsRestart: false needsRestart: false
@@ -69,6 +71,25 @@ window.PageAdmin = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
updateSettings() { updateSettings() {
if (this.shouldConfirmRouteAccess()) {
LNbits.utils
.confirmDialog(this.$t('route_access_save_confirm'))
.onOk(() => this.persistSettings())
return
}
this.persistSettings()
},
shouldConfirmRouteAccess() {
const fields = [
'lnbits_route_access_control_enabled',
'lnbits_route_access_whitelist',
'lnbits_route_access_blacklist'
]
return fields.some(
field => !_.isEqual(this.settings[field], this.formData[field])
)
},
persistSettings() {
const data = _.omit(this.formData, [ const data = _.omit(this.formData, [
'is_super_user', 'is_super_user',
'lnbits_allowed_funding_sources', 'lnbits_allowed_funding_sources',
@@ -296,6 +296,56 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-12">
<p v-text="$t('route_access_control')"></p>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-toggle
v-model="formData.lnbits_route_access_control_enabled"
:label="$t('route_access_control_enable')"
></q-toggle>
<div
class="text-caption text-grey-6 q-mt-xs"
v-text="$t('route_access_control_hint')"
></div>
</div>
<div class="col-12 col-md-6">
<q-select
filled
multiple
use-chips
use-input
input-debounce="0"
new-value-mode="add-unique"
@new-value="addRouteOption"
:options="routeOptionsFiltered"
:loading="routeOptionsLoading"
@filter="filterRouteOptions"
v-model="formData.lnbits_route_access_whitelist"
:label="$t('route_access_whitelist_label')"
:hint="$t('route_access_whitelist_hint')"
></q-select>
</div>
<div class="col-12 col-md-6">
<q-select
filled
multiple
use-chips
use-input
input-debounce="0"
new-value-mode="add-unique"
@new-value="addRouteOption"
:options="routeOptionsFiltered"
:loading="routeOptionsLoading"
@filter="filterRouteOptions"
v-model="formData.lnbits_route_access_blacklist"
:label="$t('route_access_blacklist_label')"
:hint="$t('route_access_blacklist_hint')"
></q-select>
</div>
</div>
</div>
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p> <p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -86,6 +86,42 @@
></q-input> ></q-input>
</div> </div>
</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="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_badge')"></span></p> <p><span v-text="$t('ui_custom_badge')"></span></p>
@@ -71,8 +71,11 @@
<span>OFFLINE</span> <span>OFFLINE</span>
</q-badge> </q-badge>
<lnbits-language-dropdown></lnbits-language-dropdown> <lnbits-language-dropdown
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm"> @language-changed="handleLanguageChanged({locale: $event})"
></lnbits-language-dropdown>
<q-btn-dropdown v-if="g.user" flat rounded size="md" class="q-pl-sm">
<template v-slot:label> <template v-slot:label>
<q-avatar <q-avatar
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''" v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
@@ -1,5 +1,11 @@
<template id="lnbits-language-dropdown"> <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-list v-for="(lang, index) in langs" :key="index">
<q-item <q-item
clickable clickable
+44 -17
View File
@@ -333,6 +333,16 @@
class="q-mb-md" class="q-mb-md"
> >
</q-input> </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 <q-input
v-model="g.user.external_id" v-model="g.user.external_id"
:label="$t('external_id')" :label="$t('external_id')"
@@ -380,22 +390,11 @@
<span v-text="$t('language')"></span> <span v-text="$t('language')"></span>
</div> </div>
<div class="col-8"> <div class="col-8">
<lnbits-language-dropdown /> <lnbits-language-dropdown
</div> @language-changed="
</div> siteCustomisationChanged({locale: $event})
<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>
</div> </div>
</div> </div>
@@ -407,7 +406,9 @@
<q-btn <q-btn
v-for="theme in themeOptions" v-for="theme in themeOptions"
:key="theme.name" :key="theme.name"
@click="g.themeChoice = theme.name" @click="
siteCustomisationChanged({themeChoice: theme.name})
"
:color="theme.color" :color="theme.color"
dense dense
flat flat
@@ -427,6 +428,9 @@
<q-input <q-input
v-model="g.bgimageChoice" v-model="g.bgimageChoice"
:label="$t('background_image')" :label="$t('background_image')"
@update:model-value="
siteCustomisationChanged({bgimageChoice: $event})
"
> >
<q-tooltip <q-tooltip
><span v-text="$t('background_image')"></span ><span v-text="$t('background_image')"></span
@@ -445,6 +449,9 @@
round round
icon="gradient" icon="gradient"
v-model="g.gradientChoice" v-model="g.gradientChoice"
@update:model-value="
siteCustomisationChanged({gradientChoice: $event})
"
> >
<q-tooltip <q-tooltip
><span v-text="$t('toggle_gradient')"></span ><span v-text="$t('toggle_gradient')"></span
@@ -463,6 +470,9 @@
flat flat
round round
v-model="g.darkChoice" v-model="g.darkChoice"
@update:model-value="
siteCustomisationChanged({darkChoice: $event})
"
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'" :icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
size="sm" size="sm"
> >
@@ -481,6 +491,9 @@
v-model="g.borderChoice" v-model="g.borderChoice"
:options="borderOptions" :options="borderOptions"
label="Borders" label="Borders"
@update:model-value="
siteCustomisationChanged({borderChoice: $event})
"
> >
<q-tooltip <q-tooltip
><span v-text="$t('border_choices')"></span ><span v-text="$t('border_choices')"></span
@@ -508,6 +521,9 @@
v-model="g.reactionChoice" v-model="g.reactionChoice"
:options="reactionOptions" :options="reactionOptions"
label="Reactions" label="Reactions"
@update:model-value="
siteCustomisationChanged({reactionChoice: $event})
"
> >
<q-tooltip <q-tooltip
><span v-text="$t('payment_reactions')"></span ><span v-text="$t('payment_reactions')"></span
@@ -515,6 +531,17 @@
</q-select> </q-select>
</div> </div>
</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>
<q-tab-panel name="notifications"> <q-tab-panel name="notifications">
<q-card-section> <q-card-section>
+13
View File
@@ -171,6 +171,19 @@
><span v-text="$t('camera_tooltip')"></span ><span v-text="$t('camera_tooltip')"></span
></q-tooltip> ></q-tooltip>
</q-btn> </q-btn>
<div
v-if="WALLET_FEATURED_BUTTON_URL"
class="float-right q-mt-sm q-ml-sm"
>
<q-btn
color="primary"
:label="WALLET_FEATURED_BUTTON_LABEL"
:icon="WALLET_FEATURED_BUTTON_ICON || undefined"
size="sm"
:to="WALLET_FEATURED_BUTTON_URL"
>
</q-btn>
</div>
<lnbits-update-balance <lnbits-update-balance
v-if="$q.screen.gt.md" v-if="$q.screen.gt.md"
:wallet_id="this.g.wallet.id" :wallet_id="this.g.wallet.id"
Generated
+1 -1
View File
@@ -4638,4 +4638,4 @@ migration = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "6c17d723baa390c36ecf6f861166878f2619acd47f878cf9253c5614ba25b9ea" content-hash = "f63a5be62359f837513e01a8b0e5282a3b4509ea97439ba0c8226ccf3c158ceb"
-1
View File
@@ -9,7 +9,6 @@ readme = "README.md"
dependencies = [ dependencies = [
"bech32==1.2.0", "bech32==1.2.0",
"click==8.3.1", "click==8.3.1",
"ecdsa==0.19.1",
"fastapi==0.116.1", "fastapi==0.116.1",
"starlette==0.47.1", "starlette==0.47.1",
"httpx==0.27.2", "httpx==0.27.2",
+3 -9
View File
@@ -2016,9 +2016,7 @@ async def test_api_update_user_labels(http_client: AsyncClient):
] ]
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra) data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
assert data.extra assert data.extra
response = await http_client.put( response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200 assert response.status_code == 200
user_data = response.json() user_data = response.json()
assert len(user_data["extra"]["labels"]) == 2 assert len(user_data["extra"]["labels"]) == 2
@@ -2028,18 +2026,14 @@ async def test_api_update_user_labels(http_client: AsyncClient):
assert user_data["extra"]["labels"][1]["color"] == "#00FF00" assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
data.extra.labels = [] data.extra.labels = []
response = await http_client.put( response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200 assert response.status_code == 200
user_data = response.json() user_data = response.json()
assert len(user_data["extra"]["labels"]) == 0 assert len(user_data["extra"]["labels"]) == 0
json_data = data.dict() json_data = data.dict()
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]} json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
response = await http_client.put( response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=json_data)
"/api/v1/auth/update?usr=" + user.id, json=json_data
)
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
Generated
-2
View File
@@ -1256,7 +1256,6 @@ dependencies = [
{ name = "bech32" }, { name = "bech32" },
{ name = "bolt11" }, { name = "bolt11" },
{ name = "click" }, { name = "click" },
{ name = "ecdsa" },
{ name = "embit" }, { name = "embit" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastapi-sso" }, { name = "fastapi-sso" },
@@ -1340,7 +1339,6 @@ requires-dist = [
{ name = "breez-sdk", marker = "extra == 'breez'", specifier = "==0.8.0" }, { name = "breez-sdk", marker = "extra == 'breez'", specifier = "==0.8.0" },
{ name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "==0.11.11" }, { name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "==0.11.11" },
{ name = "click", specifier = "==8.3.1" }, { name = "click", specifier = "==8.3.1" },
{ name = "ecdsa", specifier = "==0.19.1" },
{ name = "embit", specifier = "==0.8.0" }, { name = "embit", specifier = "==0.8.0" },
{ name = "fastapi", specifier = "==0.116.1" }, { name = "fastapi", specifier = "==0.116.1" },
{ name = "fastapi-sso", specifier = "==0.19.0" }, { name = "fastapi-sso", specifier = "==0.19.0" },