Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962a09bb30 | ||
|
|
92c86eb837 | ||
|
|
dadff18a9c | ||
|
|
87c1684a63 | ||
|
|
91354f8be4 | ||
|
|
5f5d45e89f | ||
|
|
52bb125a25 | ||
|
|
2ca1ed897c |
@@ -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 ]
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -66,6 +66,7 @@ from .middleware import (
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
add_route_access_middleware,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
|
||||
@@ -192,6 +193,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
# adds security middleware
|
||||
add_ip_block_middleware(app)
|
||||
add_route_access_middleware(app)
|
||||
add_ratelimit_middleware(app)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -467,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:
|
||||
@@ -483,6 +483,22 @@ 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:
|
||||
|
||||
@@ -98,6 +98,9 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
"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,
|
||||
"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,
|
||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
@@ -18,6 +19,53 @@ from lnbits.core.models import AuditEntry
|
||||
from lnbits.helpers import normalize_path, template_renderer
|
||||
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:
|
||||
# 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)
|
||||
|
||||
|
||||
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):
|
||||
@app.middleware("http")
|
||||
async def first_install_middleware(request: Request, call_next):
|
||||
|
||||
@@ -252,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(
|
||||
@@ -416,6 +421,9 @@ class SecuritySettings(LNbitsSettings):
|
||||
lnbits_rate_limit_unit: str = Field(default="minute")
|
||||
lnbits_allowed_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(
|
||||
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||
)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -363,6 +363,18 @@ window.localisation.en = {
|
||||
watchdog: 'Watchdog',
|
||||
server_logs: 'Server Logs',
|
||||
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_tools: 'Security tools',
|
||||
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_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',
|
||||
|
||||
@@ -154,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 => {
|
||||
|
||||
@@ -14,7 +14,8 @@ window.LNbits = {
|
||||
fiat_providers: data.fiat_providers || [],
|
||||
super_user: data.super_user,
|
||||
extra: data.extra ?? {},
|
||||
hasPassword: data.has_password ?? false
|
||||
hasPassword: data.has_password ?? false,
|
||||
uiCustomization: data.ui_customization || {}
|
||||
}
|
||||
const mapWallet = this.wallet
|
||||
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
|
||||
|
||||
@@ -8,11 +8,58 @@ window.app.component('lnbits-admin-security', {
|
||||
serverlogEnabled: false,
|
||||
nostrAcceptedUrl: '',
|
||||
formAllowedIPs: '',
|
||||
formCallbackUrlRule: ''
|
||||
formCallbackUrlRule: '',
|
||||
routeOptions: [],
|
||||
routeOptionsFiltered: [],
|
||||
routeOptionsLoading: false
|
||||
}
|
||||
},
|
||||
created() {},
|
||||
created() {
|
||||
this.loadOpenApiRoutes()
|
||||
},
|
||||
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() {
|
||||
const allowedIPs = this.formAllowedIPs.trim()
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
|
||||
@@ -69,6 +69,17 @@ window.app.component('lnbits-header', {
|
||||
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,5 +1,16 @@
|
||||
window.app.component('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: {
|
||||
activeLanguage(lang) {
|
||||
return window.i18n.global.locale === lang
|
||||
@@ -8,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() {
|
||||
|
||||
@@ -9,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)
|
||||
},
|
||||
@@ -123,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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,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',
|
||||
@@ -213,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({
|
||||
@@ -680,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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ window.PageAdmin = {
|
||||
lnbits_exchange_rate_providers: [],
|
||||
lnbits_audit_exclude_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,
|
||||
needsRestart: false
|
||||
@@ -69,6 +71,25 @@ window.PageAdmin = {
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
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, [
|
||||
'is_super_user',
|
||||
'lnbits_allowed_funding_sources',
|
||||
|
||||
@@ -296,6 +296,56 @@
|
||||
</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">
|
||||
<p v-text="$t('rate_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,8 +71,11 @@
|
||||
<span>OFFLINE</span>
|
||||
</q-badge>
|
||||
|
||||
<lnbits-language-dropdown></lnbits-language-dropdown>
|
||||
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
|
||||
<lnbits-language-dropdown
|
||||
@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>
|
||||
<q-avatar
|
||||
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -333,6 +333,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 +390,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 +406,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 +428,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 +449,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 +470,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 +491,9 @@
|
||||
v-model="g.borderChoice"
|
||||
:options="borderOptions"
|
||||
label="Borders"
|
||||
@update:model-value="
|
||||
siteCustomisationChanged({borderChoice: $event})
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('border_choices')"></span
|
||||
@@ -508,6 +521,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 +531,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>
|
||||
|
||||
@@ -171,6 +171,19 @@
|
||||
><span v-text="$t('camera_tooltip')"></span
|
||||
></q-tooltip>
|
||||
</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
|
||||
v-if="$q.screen.gt.md"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
|
||||
Generated
+1
-1
@@ -4638,4 +4638,4 @@ migration = ["psycopg2-binary"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "6c17d723baa390c36ecf6f861166878f2619acd47f878cf9253c5614ba25b9ea"
|
||||
content-hash = "f63a5be62359f837513e01a8b0e5282a3b4509ea97439ba0c8226ccf3c158ceb"
|
||||
|
||||
@@ -9,7 +9,6 @@ readme = "README.md"
|
||||
dependencies = [
|
||||
"bech32==1.2.0",
|
||||
"click==8.3.1",
|
||||
"ecdsa==0.19.1",
|
||||
"fastapi==0.116.1",
|
||||
"starlette==0.47.1",
|
||||
"httpx==0.27.2",
|
||||
|
||||
@@ -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)
|
||||
assert data.extra
|
||||
response = await http_client.put(
|
||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
||||
)
|
||||
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||
assert response.status_code == 200
|
||||
user_data = response.json()
|
||||
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"
|
||||
|
||||
data.extra.labels = []
|
||||
response = await http_client.put(
|
||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
||||
)
|
||||
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||
assert response.status_code == 200
|
||||
user_data = response.json()
|
||||
assert len(user_data["extra"]["labels"]) == 0
|
||||
|
||||
json_data = data.dict()
|
||||
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
|
||||
response = await http_client.put(
|
||||
"/api/v1/auth/update?usr=" + user.id, json=json_data
|
||||
)
|
||||
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=json_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
@@ -1256,7 +1256,6 @@ dependencies = [
|
||||
{ name = "bech32" },
|
||||
{ name = "bolt11" },
|
||||
{ name = "click" },
|
||||
{ name = "ecdsa" },
|
||||
{ name = "embit" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastapi-sso" },
|
||||
@@ -1340,7 +1339,6 @@ requires-dist = [
|
||||
{ name = "breez-sdk", marker = "extra == 'breez'", specifier = "==0.8.0" },
|
||||
{ name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "==0.11.11" },
|
||||
{ name = "click", specifier = "==8.3.1" },
|
||||
{ name = "ecdsa", specifier = "==0.19.1" },
|
||||
{ name = "embit", specifier = "==0.8.0" },
|
||||
{ name = "fastapi", specifier = "==0.116.1" },
|
||||
{ name = "fastapi-sso", specifier = "==0.19.0" },
|
||||
|
||||
Reference in New Issue
Block a user