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
28 changed files with 68 additions and 925 deletions
+21 -75
View File
@@ -4,12 +4,10 @@ from typing import Any
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
from lnbits.core.crud.wallets import create_wallet, get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import (
Account,
@@ -43,7 +41,6 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
await clear_user_id_cache(user_id)
async def get_accounts(
@@ -72,7 +69,6 @@ async def get_accounts(
accounts.email,
accounts.pubkey,
accounts.external_id,
accounts.activated,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
@@ -97,18 +93,12 @@ async def get_accounts(
)
async def get_account(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE id = :id AND (activated = true OR activated = :activated)
""",
{"id": user_id, "activated": active_only},
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
Account,
)
@@ -134,79 +124,55 @@ async def delete_accounts_no_wallets(
async def get_account_by_username(
username: str, active_only: bool = True, conn: Connection | None = None
username: str, conn: Connection | None = None
) -> Account | None:
if len(username) == 0:
return None
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""",
{"username": username.lower(), "activated": active_only},
"SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username.lower()},
Account,
)
async def get_account_by_pubkey(
pubkey: str, active_only: bool = True, conn: Connection | None = None
pubkey: str, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""",
{"pubkey": pubkey.lower(), "activated": active_only},
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
Account,
)
async def get_account_by_email(
email: str, active_only: bool = True, conn: Connection | None = None
email: str, conn: Connection | None = None
) -> Account | None:
if len(email) == 0:
return None
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""",
{"email": email.lower(), "activated": active_only},
"SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email.lower()},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str,
active_only: bool = True,
conn: Connection | None = None,
username_or_email: str, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
(LOWER(email) = :value or LOWER(username) = :value)
AND (activated = true OR activated = :activated)
WHERE LOWER(email) = :value or LOWER(username) = :value
""",
{"value": username_or_email.lower(), "activated": active_only},
{"value": username_or_email.lower()},
Account,
)
async def get_user(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> User | None:
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, active_only, conn=conn)
account = await get_account(user_id, conn=conn)
if not account:
return None
return await get_user_from_account(account, conn=conn)
@@ -225,7 +191,6 @@ async def get_user_from_account(
return User(
id=account.id,
activated=account.activated,
email=account.email,
username=account.username,
pubkey=account.pubkey,
@@ -251,31 +216,12 @@ async def update_user_access_control_list(
async def get_user_access_control_lists(
user_id: str, active_only: bool = True, conn: Connection | None = None
user_id: str, conn: Connection | None = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"""
SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND (activated = true OR activated = :activated)
""",
{"user_id": user_id, "activated": active_only},
"SELECT id, access_control_list FROM accounts WHERE id = :id",
{"id": user_id},
UserAcls,
)
return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True)
if user:
clear_user_cache(user)
def clear_user_cache(user: User):
user_cache_key: str | None = cache.pop(
f"auth:user:cache_key:{sha256s(user.id)}", None
)
if user_cache_key:
cache.pop(user_cache_key)
for wallet in user.wallets:
clear_wallet_cache(wallet)
+8 -20
View File
@@ -50,7 +50,7 @@ async def delete_wallet(
deleted: bool = True,
conn: Connection | None = None,
) -> None:
clear_wallet_id_cache(wallet_id)
_clear_wallet_cache(wallet_id)
now = int(time())
await (conn or db).execute(
@@ -65,7 +65,7 @@ async def delete_wallet(
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
clear_wallet_id_cache(wallet_id)
_clear_wallet_cache(wallet_id)
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -75,7 +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_id_cache(wallet_id)
_clear_wallet_cache(wallet_id)
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -226,14 +226,11 @@ async def get_wallet_for_key(
) -> Wallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.*, COALESCE((
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
Wallet,
@@ -253,11 +250,8 @@ async def get_base_wallet_for_key(
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
BaseWallet,
@@ -300,14 +294,8 @@ async def get_total_balance(conn: Connection | None = None):
return row.get("balance", 0) or 0
def clear_wallet_id_cache(wallet_id: str):
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}")
def clear_wallet_cache(wallet: Wallet):
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
-8
View File
@@ -856,11 +856,3 @@ async def m043_add_ui_customization_to_accounts(db: Connection):
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
async def m044_add_activated_to_accounts(db: Connection):
"""
Adds activated column to accounts.
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
-4
View File
@@ -181,7 +181,6 @@ class AccountId(BaseModel):
class Account(AccountId):
activated: bool = True
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
@@ -242,7 +241,6 @@ class Account(AccountId):
class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
@@ -278,7 +276,6 @@ class AccountFilters(FilterModel):
class User(BaseModel):
id: str
activated: bool = True
created_at: datetime
updated_at: datetime
email: str | None = None
@@ -318,7 +315,6 @@ class RegisterUser(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)
invitation_code: str | None = Query(default=None, min_length=1, max_length=256)
class CreateUser(BaseModel):
-24
View File
@@ -3,10 +3,8 @@ from uuid import uuid4
from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.db import Connection
from lnbits.settings import (
EditableSettings,
@@ -194,25 +192,3 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def check_register_activation_settings(data: RegisterUser):
if not settings.lnbits_require_user_activation:
return None
if settings.lnbits_user_activation_by_invitation_code:
code = data.invitation_code.strip() if data.invitation_code else ""
if len(code) == 0:
raise ValueError("Invitation code cannot be empty.")
if code == settings.lnbits_register_reusable_activation_code:
return None
if code in settings.lnbits_register_one_time_activation_codes:
settings.lnbits_register_one_time_activation_codes.remove(code)
await set_settings_field(
"lnbits_register_one_time_activation_codes",
settings.lnbits_register_one_time_activation_codes,
)
return None
raise ValueError("Invalid invitation code.")
raise ValueError("No activation method provided.")
+4 -12
View File
@@ -26,10 +26,7 @@ from lnbits.core.models.users import (
UpdateAccessControlList,
)
from lnbits.core.services import create_user_account
from lnbits.core.services.users import (
check_register_activation_settings,
update_user_account,
)
from lnbits.core.services.users import update_user_account
from lnbits.decorators import (
access_token_payload,
check_account_exists,
@@ -89,7 +86,6 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email)
@@ -98,7 +94,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"], active_only=False)
account = await get_account_by_pubkey(event["pubkey"])
if not account:
account = Account(
id=uuid4().hex,
@@ -106,8 +102,6 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"),
)
await create_user_account(account)
if not account.activated:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
return _auth_success_response(account.username or "", account.id, account.email)
@@ -363,14 +357,12 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username, active_only=False):
if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
await check_register_activation_settings(data)
account = Account(
id=uuid4().hex,
email=data.email,
@@ -535,7 +527,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet"
account = await get_account_by_email(email, active_only=False)
account = await get_account_by_email(email)
if verified_user_id:
if account:
+2 -40
View File
@@ -20,7 +20,6 @@ from lnbits.core.crud import (
update_admin_settings,
update_wallet,
)
from lnbits.core.crud.users import clear_user_id_cache, get_account, update_account
from lnbits.core.crud.wallets import delete_wallet_by_id
from lnbits.core.models import (
AccountFilters,
@@ -74,7 +73,7 @@ async def api_get_users(
summary="Get user by Id",
)
async def api_get_user(user_id: str) -> User:
user = await get_user(user_id, active_only=False)
user = await get_user(user_id)
if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user
@@ -198,7 +197,7 @@ async def api_users_reset_password(user_id: str) -> str:
return f"reset_key_{reset_key_b64}"
@users_router.put(
@users_router.get(
"/user/{user_id}/admin",
dependencies=[Depends(check_super_user)],
name="Give or revoke admin permsisions to a user",
@@ -221,43 +220,6 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
)
@users_router.put(
"/user/{user_id}/activate",
name="Activate or deactivate a user",
)
async def api_users_toggle_activated(
user_id: str, admin_account: Account = Depends(check_admin)
) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot deactivate super user.",
)
if user_id == admin_account.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You cannot deactivate yourself.",
)
if settings.is_admin_user(user_id):
settings.lnbits_admin_users.remove(user_id)
user_account = await get_account(user_id, active_only=False)
if not user_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="User not found.",
)
user_account.activated = not user_account.activated
await update_account(user_account)
await clear_user_id_cache(user_id)
return SimpleStatus(
success=True,
message=f"User {'activated' if user_account.activated else 'deactivated'}.",
)
@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, deleted=None)
+4 -2
View File
@@ -9,7 +9,6 @@ from fastapi import (
)
from lnbits.core.crud.wallets import (
clear_wallet_cache,
create_wallet,
get_wallets_paginated,
)
@@ -38,6 +37,7 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.utils.cache import cache
from ..crud import (
delete_wallet,
@@ -134,7 +134,9 @@ async def api_reset_wallet_keys(
if not wallet or wallet.user != account_id.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
clear_wallet_cache(wallet)
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex
-1
View File
@@ -261,7 +261,6 @@ async def check_account_id_exists(
account_id,
expiry=settings.auth_authentication_cache_minutes * 60,
)
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
return account_id
-16
View File
@@ -41,14 +41,6 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True)
lnbits_require_user_activation: bool = Field(default=False)
lnbits_user_activation_by_email: bool = Field(default=False)
lnbits_user_activation_by_payment: bool = Field(default=False)
lnbits_user_activation_by_invitation_code: bool = Field(default=False)
lnbits_register_reusable_activation_code: str = Field(default="")
lnbits_register_one_time_activation_codes: list[str] = Field(default=[])
@property
def new_accounts_allowed(self) -> bool:
@@ -1196,11 +1188,6 @@ class PublicSettings(BaseModel):
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")
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
lnbits_user_activation_by_invitation_code: bool = Field(
alias="userActivationByInvitationCode"
)
@classmethod
def from_settings(cls, settings: Settings):
@@ -1252,9 +1239,6 @@ class PublicSettings(BaseModel):
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
userActivationByEmail=settings.lnbits_user_activation_by_email,
userActivationByPayment=settings.lnbits_user_activation_by_payment,
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,
)
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -25
View File
@@ -171,7 +171,6 @@ window.localisation.en = {
drop_db: 'Remove Data',
enable: 'Enable',
enabled: 'Enabled',
disabled: 'Disabled',
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user',
disable: 'Disable',
@@ -179,8 +178,6 @@ window.localisation.en = {
installed: 'Installed',
activated: 'Activated',
deactivated: 'Deactivated',
activate: 'Activate',
deactivate: 'Deactivate',
release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured',
@@ -189,8 +186,6 @@ window.localisation.en = {
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
@@ -285,7 +280,7 @@ window.localisation.en = {
'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to.',
'List of identifiers to send notifications to',
notifications_telegram_config: 'Telegram Configuration',
notifications_enable_telegram: 'Enable Telegram',
@@ -709,25 +704,6 @@ window.localisation.en = {
allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
require_user_activation: 'Require user activation',
require_user_activation_desc:
'New users will be activated only after they pass one of the confirmation methods. Admins can activate users manually from the admin panel.',
reusable_activation_code: 'Reusable activation code',
reusable_activation_code_label: 'Reusable activation code',
reusable_activation_code_hint:
'This activation code can be used multiple times by different users.',
one_time_activation_code: 'One-time activation codes',
one_time_activation_code_label: 'Add activation code',
one_time_activation_code_hint:
'List of one-time activation codes. Each code can be used only once, then will be reomved from the list.',
invitation_code: 'Invitation Code',
invitation_code_hint: 'The invitation code that you have received.',
email: 'Email',
email_confirmation_hint: 'Email address to send the confirmation code to.',
nostr_identifier: 'Nostr Identifier',
nostr_identifier_hint:
'Nostr nip5 identifier or <npub> to send the confirmation code to.',
new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
+3 -4
View File
@@ -66,7 +66,7 @@ window._lnbitsApi = {
name: name
})
},
register(username, email, password, password_repeat, invitation_code) {
register(username, email, password, password_repeat) {
return axios({
method: 'POST',
url: '/api/v1/auth/register',
@@ -74,8 +74,7 @@ window._lnbitsApi = {
username,
email,
password,
password_repeat,
invitation_code
password_repeat
}
})
},
@@ -148,7 +147,7 @@ window._lnbitsApi = {
name: name,
wallet_type: walletType,
...opts
})
}).catch(LNbits.utils.notifyApiError)
},
updateWallet(name, wallet) {
return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
+1 -27
View File
@@ -432,10 +432,8 @@ window.app.component('username-password', {
username: String,
password_1: String,
password_2: String,
invitationCode: String,
resetKey: String
},
data() {
return {
oauth: [
@@ -447,11 +445,7 @@ window.app.component('username-password', {
username: this.userName,
password: this.password_1,
passwordRepeat: this.password_2,
reset_key: this.resetKey,
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false
reset_key: this.resetKey
}
},
methods: {
@@ -464,7 +458,6 @@ window.app.component('username-password', {
this.$emit('update:userName', this.username)
this.$emit('update:password_1', this.password)
this.$emit('update:password_2', this.passwordRepeat)
this.$emit('update:invitationCode', this.confirmationCode)
this.$emit('register')
},
reset() {
@@ -556,25 +549,6 @@ window.app.component('username-password', {
computed: {
showOauth() {
return this.oauth.some(m => this.authMethods.includes(m))
},
disableRegister() {
const usernameOK = !!this.username
const passwordOK = !!this.password && this.password.length >= 8
const passwordsMatch = this.password === this.passwordRepeat
const codeOk =
this.confirmationMethodsCount === 0 ||
this.confirmationMethod !== 'code' ||
this.confirmationCode.length > 0
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
},
confirmationMethodsCount() {
const methods = [
this.g.settings.userActivationByEmail,
this.g.settings.userActivationByPayment,
this.g.settings.userActivationByInvitationCode
]
return methods.filter(Boolean).length
}
},
created() {}
@@ -4,9 +4,7 @@ window.app.component('lnbits-admin-users', {
data() {
return {
formAddUser: '',
formAddAdmin: '',
formAddActivationCode: '',
showReusableActivationCode: false
formAddAdmin: ''
}
},
methods: {
@@ -33,24 +31,6 @@ window.app.component('lnbits-admin-users', {
removeAdminUser(user) {
let admin_users = this.formData.lnbits_admin_users
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
},
addOneTimeActivationCode() {
const code = this.formAddActivationCode
const activationCodes =
this.formData.lnbits_register_one_time_activation_codes
if (code?.length && !activationCodes.includes(code)) {
this.formData.lnbits_register_one_time_activation_codes = [
...activationCodes,
code
]
this.formAddActivationCode = ''
}
},
removeOneTimeActivationCode(code) {
const codes = this.formData.lnbits_register_one_time_activation_codes
this.formData.lnbits_register_one_time_activation_codes = codes.filter(
u => u !== code
)
}
}
})
@@ -97,7 +97,6 @@ window.app.component('lnbits-wallet-new', {
this.g.lastWalletId = res.data.id
this.$router.push(`/wallet/${res.data.id}`)
})
.catch(LNbits.utils.notifyApiError)
}
},
created() {
+1 -4
View File
@@ -11,7 +11,6 @@ window.PageHome = {
email: '',
password: '',
passwordRepeat: '',
invitationCode: '',
walletName: '',
signup: false
}
@@ -41,7 +40,6 @@ window.PageHome = {
this.username = null
this.password = null
this.passwordRepeat = null
this.invitationCode = null
this.authAction = 'register'
this.authMethod = authMethod
@@ -53,8 +51,7 @@ window.PageHome = {
this.username,
this.email,
this.password,
this.passwordRepeat,
this.invitationCode
this.passwordRepeat
)
this.refreshAuthUser()
} catch (e) {
+4 -17
View File
@@ -70,10 +70,10 @@ window.PageUsers = {
usersTable: {
columns: [
{
name: 'activated',
name: 'admin',
align: 'left',
label: this.$t('activated'),
field: 'activated',
label: 'Admin',
field: 'admin',
sortable: false
},
{
@@ -401,7 +401,7 @@ window.PageUsers = {
toggleAdmin(userId) {
LNbits.api
.request('PUT', `/users/api/v1/user/${userId}/admin`)
.request('GET', `/users/api/v1/user/${userId}/admin`)
.then(() => {
this.fetchUsers()
Quasar.Notify.create({
@@ -412,19 +412,6 @@ window.PageUsers = {
})
.catch(LNbits.utils.notifyApiError)
},
toggleUserActivated(userId) {
LNbits.api
.request('PUT', `/users/api/v1/user/${userId}/activate`)
.then(res => {
this.fetchUsers()
Quasar.Notify.create({
type: 'positive',
message: res.data.message,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
async showAccountPage(user_id) {
this.activeUser.showPassword = false
this.activeUser.showUserId = false
+6 -80
View File
@@ -814,90 +814,16 @@ include('components/lnbits-error.vue') %}
type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<div
v-if="confirmationMethodsCount > 1"
class="row justify-center q-mb-md"
>
<q-tabs
v-model="confirmationMethod"
dense
active-color="primary"
indicator-color="primary"
>
<q-tab
v-if="g.settings.userActivationByInvitationCode"
name="code"
icon="confirmation_number"
label="Code"
></q-tab>
<q-tab
v-if="g.settings.userActivationByPayment"
name="payment"
icon="bolt"
label="Payment"
></q-tab>
<q-tab
v-if="g.settings.userActivationByEmail"
name="email"
icon="email"
label="Email"
></q-tab>
</q-tabs>
</div>
<div v-if="confirmationMethodsCount > 0" class="q-mb-md">
<q-tab-panels v-model="confirmationMethod">
<q-tab-panel name="code" class="q-pa-none">
<div
class="q-my-md q-pa-sm text-body2 text-grey-4 bg-grey-9 rounded-borders"
>
<q-icon name="info" color="orange-4" class="q-mr-xs"></q-icon>
You need an invitation code to register.
</div>
<div>
<q-input
dense
filled
v-model="confirmationCode"
:label="$t('invitation_code')"
:type="showConfirmationCode ? 'text' : 'password'"
:hint="$t('invitation_code_hint')"
>
<q-btn
@click="showConfirmationCode = !showConfirmationCode"
dense
flat
:icon="
showConfirmationCode ? 'visibility_off' : 'visibility'
"
color="grey"
></q-btn>
</q-input>
</div>
</q-tab-panel>
<q-tab-panel name="payment">
<div>payment</div>
</q-tab-panel>
<q-tab-panel name="email" class="q-pa-none">
<div>
<q-input
dense
filled
v-model="confirmationEmail"
:label="$t('email')"
:hint="$t('email_confirmation_hint')"
>
</q-input>
</div>
</q-tab-panel>
</q-tab-panels>
</div>
<div class="row justify-end">
<q-btn
unelevated
color="primary"
:disable="disableRegister"
:disable="
!password ||
!passwordRepeat ||
!username ||
password !== passwordRepeat
"
type="submit"
class="full-width"
:label="$t('create_account')"
+3 -234
View File
@@ -30,6 +30,7 @@
>
</q-chip>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_users')"></span></p>
@@ -56,10 +57,7 @@
>
</q-chip>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-6 q-mt-sm">
<br />
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('allow_creation_user')"></q-item-label>
@@ -78,237 +76,8 @@
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6 q-mt-sm">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('require_user_activation')"></q-item-label>
<q-item-label
caption
v-text="$t('require_user_activation_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_require_user_activation"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</q-card-section>
<div v-if="formData.lnbits_require_user_activation" class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-icon name="confirmation_number" size="md"></q-icon>
</q-item-section>
<q-item-section> Invitation Code </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="
formData.lnbits_user_activation_by_invitation_code
? $t('enabled')
: $t('disabled')
"
v-model="formData.lnbits_user_activation_by_invitation_code"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-card-section>
<div
class="q-my-md q-pa-sm text-body2 text-grey-4 bg-grey-9 rounded-borders"
>
<q-icon
name="info"
color="orange-4"
size="18px"
class="q-mr-xs"
></q-icon>
Users will need to provide a valid invitation code during
registration to activate their account.
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-12 col-md-6 q-pr-sm">
<p><span v-text="$t('reusable_activation_code')"></span></p>
<q-input
filled
v-model="formData.lnbits_register_reusable_activation_code"
:type="showReusableActivationCode ? 'text' : 'password'"
:label="$t('reusable_activation_code_label')"
:hint="$t('reusable_activation_code_hint')"
>
<q-btn
@click="
showReusableActivationCode = !showReusableActivationCode
"
dense
flat
:icon="
showReusableActivationCode
? 'visibility_off'
: 'visibility'
"
color="grey"
></q-btn>
<q-btn
@click="
utils.copyText(
formData.lnbits_register_reusable_activation_code
)
"
dense
flat
icon="content_copy"
color="grey"
></q-btn>
</q-input>
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('one_time_activation_code')"></span></p>
<q-input
filled
v-model="formAddActivationCode"
@keydown.enter="addOneTimeActivationCode"
type="text"
:label="$t('one_time_activation_code_label')"
:hint="$t('one_time_activation_code_hint')"
>
<q-btn
@click="addOneTimeActivationCode"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="code in formData.lnbits_register_one_time_activation_codes"
:key="code"
removable
@remove="removeOneTimeActivationCode(code)"
color="primary"
text-color="white"
:label="code"
class="ellipsis"
>
</q-chip>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<q-icon name="bolt" size="md"></q-icon>
</q-avatar>
</q-item-section>
<q-item-section> Payment </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
disable
:label="
formData.lnbits_user_activation_by_payment
? $t('enabled')
: $t('disabled')
"
v-model="formData.lnbits_user_activation_by_payment"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-card-section>
<div
class="q-my-md q-pa-sm text-body2 text-grey-4 bg-grey-9 rounded-borders"
>
<q-icon
name="info"
color="orange-4"
size="18px"
class="q-mr-xs"
></q-icon>
Users will need to make a payment during registration to
activate their account.
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<q-icon name="email"></q-icon>
</q-avatar>
</q-item-section>
<q-item-section> Email </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
disable
size="md"
:label="
formData.lnbits_user_activation_by_email
? $t('enabled')
: $t('disabled')
"
v-model="formData.lnbits_user_activation_by_email"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-card-section>
<div
class="q-my-md q-pa-sm text-body2 text-grey-4 bg-grey-9 rounded-borders"
>
<q-icon
name="info"
color="orange-4"
size="18px"
class="q-mr-xs"
></q-icon>
Users will receive a confirmation email.
</div>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</template>
-1
View File
@@ -63,7 +63,6 @@
v-model:user-name="username"
v-model:password_1="password"
v-model:password_2="passwordRepeat"
v-model:invitation-code="invitationCode"
v-model:reset-key="reset_key"
@login="login"
@register="register"
+5 -33
View File
@@ -218,26 +218,6 @@
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-if="activeUser.data.id"
outline
color="primary"
class="q-ml-md"
>
<q-toggle
size="xs"
color="secondary"
v-model="activeUser.data.admin"
@update:model-value="toggleAdmin(activeUser.data.id)"
:label="
activeUser.data.admin
? $t('revoke_admin')
: $t('make_user_admin')
"
>
</q-toggle>
</q-btn>
<q-btn
v-else
@click="createUser()"
@@ -610,9 +590,9 @@
<q-btn
@click="showAccountPage(props.row.id)"
round
:icon="props.row.is_admin ? 'admin_panel_settings' : 'edit'"
icon="edit"
size="sm"
:color="props.row.is_admin ? 'primary' : 'secondary'"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
@@ -625,18 +605,10 @@
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.activated"
@update:model-value="toggleUserActivated(props.row.id)"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip
><span
v-text="
props.row.activated
? $t('deactivate')
: $t('activate')
"
></span
></q-tooltip>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.4.1"
version = "1.4.2"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
-142
View File
@@ -297,148 +297,6 @@ async def test_register_ok(http_client: AsyncClient):
), f"Expected 1 default wallet, not {len(user.wallets)}."
@pytest.mark.anyio
async def test_register_no_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 400
assert response.json().get("detail") == "No activation method provided."
settings.lnbits_user_activation_by_invitation_code = True
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 400, "User creation blocked without activation code."
assert response.json().get("detail") == "Invitation code cannot be empty."
@pytest.mark.anyio
async def test_register_invalid_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = "foo"
settings.lnbits_register_one_time_activation_codes = ["baz", "qux"]
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "bar",
},
)
assert response.status_code == 400
assert response.json().get("detail") == "Invalid invitation code."
@pytest.mark.anyio
async def test_register_reusable_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = "foo"
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "foo",
},
)
assert response.status_code == 200, "User created with reusable code."
assert response.json().get("access_token") is not None
# Register again with the same code
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "foo",
},
)
assert response.status_code == 200, "User created with reusable code."
assert response.json().get("access_token") is not None
@pytest.mark.anyio
async def test_register_one_time_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = "foo"
settings.lnbits_register_one_time_activation_codes = ["baz", "qux"]
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "baz",
},
)
assert response.status_code == 200, "User created with one-time code."
assert response.json().get("access_token") is not None
# Register again with the same code
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "baz",
},
)
assert response.status_code == 400, "Invalid invitation code."
assert response.json().get("detail") == "Invalid invitation code."
@pytest.mark.anyio
async def test_register_email_twice(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
-126
View File
@@ -1,13 +1,11 @@
from typing import Any
from uuid import uuid4
import jwt
import pytest
import shortuuid
from httpx import AsyncClient
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.models import AccessTokenPayload, Payment
from lnbits.core.models.users import Account, User
from lnbits.core.services.users import create_user_account
from lnbits.settings import Settings
@@ -612,127 +610,3 @@ async def test_delete_and_undelete_wallet(http_client: AsyncClient, superuser_to
undeleted_wallet = next((w for w in wallets if w.id == wallet_id), None)
assert undeleted_wallet is not None
assert undeleted_wallet.deleted is False
@pytest.mark.anyio
async def test_user_activation(
http_client: AsyncClient, invoice: Payment, settings: Settings, superuser_token: str
):
# Register a new user
username = f"u21.{shortuuid.uuid()[:8]}"
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": username,
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"{username}@lnbits.com",
},
)
access_token = response.json().get("access_token")
assert response.status_code == 200, "User created."
assert response.json().get("access_token") is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
user_id = access_token_payload.usr
assert user_id is not None
# Login works
response = await http_client.post(
"/api/v1/auth", json={"username": username, "password": "secret1234"}
)
assert response.status_code == 200, "User logs in OK"
# Deactivate the user
respones = await http_client.put(
f"/users/api/v1/user/{user_id}/activate",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert respones.status_code == 200, "User deactivated."
assert respones.json().get("message") == "User deactivated."
# Login should now fail
response = await http_client.post(
"/api/v1/auth", json={"username": username, "password": "secret1234"}
)
assert response.status_code == 401
assert response.json().get("detail") == "Invalid credentials."
response = await http_client.get(
"/api/v1/auth",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 401
assert response.json().get("detail") == "User not found."
wallets = await get_wallets(user_id=user_id)
assert len(wallets) == 1, "User's wallet still exists."
wallet = wallets[0]
response = await http_client.get(
"/api/v1/payments/paginated",
params={"limit": 2},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 404
assert response.json().get("detail") == "Wallet not found."
response = await http_client.post(
"/api/v1/payments",
json={
"out": False,
"amount": 1000,
"memo": "test payment",
},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 404
assert response.json().get("detail") == "Wallet not found."
data = {"out": True, "bolt11": invoice.bolt11}
response = await http_client.post(
"/api/v1/payments",
json=data,
headers={"x-Api-Key": wallet.adminkey},
)
assert response.status_code == 404
assert response.json().get("detail") == "Wallet not found."
# Reactivate the user
response = await http_client.put(
f"/users/api/v1/user/{user_id}/activate",
headers={"Authorization": f"Bearer {superuser_token}"},
)
print("### response", response.text)
assert response.status_code == 200
assert response.json().get("message") == "User activated."
# Login should now pass
response = await http_client.post(
"/api/v1/auth", json={"username": username, "password": "secret1234"}
)
assert response.status_code == 200, "User logs in OK again."
response = await http_client.get(
"/api/v1/payments/paginated",
params={"limit": 2},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 200
response = await http_client.post(
"/api/v1/payments",
json={
"out": False,
"amount": 1000,
"memo": "test payment",
},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 201
-4
View File
@@ -341,7 +341,3 @@ def _settings_cleanup(settings: Settings):
settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
settings.stripe_limits = FiatProviderLimits()
settings.lnbits_require_user_activation = False
settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = []
Generated
+1 -1
View File
@@ -1247,7 +1247,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.4.1"
version = "1.4.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },