Compare commits

...
Author SHA1 Message Date
Vlad Stan e0cf512d3e fix: remove nostr message 2026-02-03 15:17:49 +02:00
Vlad Stan b86720f59e feat: better wording 2026-02-03 14:40:46 +02:00
Vlad Stan 13c370fb08 feat: check invitation code 2026-02-03 11:21:47 +02:00
Vlad Stan 3b4e8ace6a feat: update settings 2026-02-03 10:29:53 +02:00
Vlad Stan 390c616998 feat: send invitation code to backend 2026-02-03 10:24:00 +02:00
Vlad Stan e5e6ee3628 feat: add confirmation options UI 2026-02-02 18:36:49 +02:00
Vlad Stan f962502610 feat: configure ui 2026-02-02 16:35:57 +02:00
Vlad Stan f40cb536ba feat: configure activation codes 2026-02-02 16:29:52 +02:00
Vlad Stan db9a9eb8c2 feat: add some info 2026-02-02 16:29:52 +02:00
Vlad Stan 8ba662ff99 refactor: reorder fields 2026-02-02 16:29:52 +02:00
Vlad Stan e5acd6188e feat: add ui config 2026-02-02 16:29:52 +02:00
Vlad Stan 46ebb6ce27 refactor: code cleanup 2026-02-02 16:29:35 +02:00
Vlad Stan c7398f6314 chore: clean-up 2026-02-02 12:37:48 +02:00
Vlad Stan efa6398c51 chore: bundle 2026-02-02 12:35:34 +02:00
Vlad Stan 7179dbfeef refactor: simplify queries 2026-02-02 12:34:57 +02:00
Vlad Stan 4021648082 refactor: use normal update 2026-02-02 12:34:57 +02:00
Vlad Stan d5236aa88f test: add more check 2026-02-02 12:34:55 +02:00
Vlad Stan ff4132ef95 test: login after user disabled does not work 2026-02-02 12:34:55 +02:00
Vlad Stan 7b2805ef46 fix: column selection 2026-02-02 12:34:47 +02:00
Vlad Stan 3bbfc2a58c fix: better message 2026-02-02 12:34:47 +02:00
Vlad Stan 00795a186d feat: add back toggle admin 2026-02-02 12:34:47 +02:00
Vlad Stan fa0c57ce3d fix: only fetch keys for activated users 2026-02-02 12:34:47 +02:00
Vlad Stan ff6959095f fix: clear cache 2026-02-02 12:34:47 +02:00
Vlad Stan e951d02c3d feat: clear cache on user deactivation 2026-02-02 12:34:47 +02:00
Vlad Stan fadad38c99 feat: basic account activate/deactivate 2026-02-02 12:34:47 +02:00
24 changed files with 770 additions and 63 deletions
+70 -21
View File
@@ -4,10 +4,12 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import create_wallet, get_wallets from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import UserAcls from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import ( from ..models import (
Account, Account,
@@ -41,6 +43,7 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
"DELETE from accounts WHERE id = :user", "DELETE from accounts WHERE id = :user",
{"user": user_id}, {"user": user_id},
) )
await clear_user_id_cache(user_id)
async def get_accounts( async def get_accounts(
@@ -69,6 +72,7 @@ async def get_accounts(
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.external_id, accounts.external_id,
accounts.activated,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat, ), 0)) as balance_msat,
@@ -93,12 +97,20 @@ async def get_accounts(
) )
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None: async def get_account(
user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> Account | None:
if len(user_id) == 0: if len(user_id) == 0:
return None return None
activate_clause = "" if activated is None else "AND activated = :activated"
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id", f"""
{"id": user_id}, SELECT * FROM accounts
WHERE id = :id {activate_clause}
""", # noqa: S608
{"id": user_id, "activated": activated},
Account, Account,
) )
@@ -124,55 +136,72 @@ async def delete_accounts_no_wallets(
async def get_account_by_username( async def get_account_by_username(
username: str, conn: Connection | None = None username: str, activated: bool = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(username) == 0: if len(username) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(username) = :username", """
{"username": username.lower()}, SELECT * FROM accounts
WHERE LOWER(username) = :username AND activated = :activated
""",
{"username": username.lower(), "activated": activated},
Account, Account,
) )
async def get_account_by_pubkey( async def get_account_by_pubkey(
pubkey: str, conn: Connection | None = None pubkey: str, activated: bool | None = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey", """
{"pubkey": pubkey.lower()}, SELECT * FROM accounts
WHERE LOWER(pubkey) = :pubkey AND activated = :activated
""",
{"pubkey": pubkey.lower(), "activated": activated},
Account, Account,
) )
async def get_account_by_email( async def get_account_by_email(
email: str, conn: Connection | None = None email: str, activated: bool = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(email) == 0: if len(email) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(email) = :email", """
{"email": email.lower()}, SELECT * FROM accounts
WHERE LOWER(email) = :email AND activated = :activated
""",
{"email": email.lower(), "activated": activated},
Account, Account,
) )
async def get_account_by_username_or_email( async def get_account_by_username_or_email(
username_or_email: str, conn: Connection | None = None username_or_email: str,
activated: bool = True,
conn: Connection | None = None,
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value WHERE (LOWER(email) = :value or LOWER(username) = :value)
AND activated = :activated
""", """,
{"value": username_or_email.lower()}, {"value": username_or_email.lower(), "activated": activated},
Account, Account,
) )
async def get_user(user_id: str, conn: Connection | None = None) -> User | None: async def get_user(
user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, conn=conn) account = await get_account(user_id, activated=activated, conn=conn)
if not account: if not account:
return None return None
return await get_user_from_account(account, conn=conn) return await get_user_from_account(account, conn=conn)
@@ -191,6 +220,7 @@ async def get_user_from_account(
return User( return User(
id=account.id, id=account.id,
activated=account.activated,
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, pubkey=account.pubkey,
@@ -216,12 +246,31 @@ async def update_user_access_control_list(
async def get_user_access_control_lists( async def get_user_access_control_lists(
user_id: str, conn: Connection | None = None user_id: str, activated: bool = True, conn: Connection | None = None
) -> UserAcls: ) -> UserAcls:
user_acls = await (conn or db).fetchone( user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id", """
{"id": user_id}, SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND activated = :activated
""",
{"user_id": user_id, "activated": activated},
UserAcls, UserAcls,
) )
return user_acls or UserAcls(id=user_id) return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, activated=None)
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)
+20 -8
View File
@@ -50,7 +50,7 @@ async def delete_wallet(
deleted: bool = True, deleted: bool = True,
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> None:
_clear_wallet_cache(wallet_id) clear_wallet_id_cache(wallet_id)
now = int(time()) now = int(time())
await (conn or db).execute( 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: async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
_clear_wallet_cache(wallet_id) clear_wallet_id_cache(wallet_id)
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet", "DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id}, {"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( async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None wallet_id: str, conn: Connection | None = None
) -> int | None: ) -> int | None:
_clear_wallet_cache(wallet_id) clear_wallet_id_cache(wallet_id)
now = int(time()) now = int(time())
result = await (conn or db).execute( result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input) # Timestamp placeholder is safe from SQL injection (not user input)
@@ -226,11 +226,14 @@ async def get_wallet_for_key(
) -> Wallet | None: ) -> Wallet | None:
wallet = await (conn or db).fetchone( wallet = await (conn or db).fetchone(
""" """
SELECT *, COALESCE(( SELECT wallets.*, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) ), 0)
AS balance_msat FROM wallets AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""", """,
{"key": key}, {"key": key},
Wallet, Wallet,
@@ -250,8 +253,11 @@ async def get_base_wallet_for_key(
) -> BaseWallet | None: ) -> BaseWallet | None:
wallet = await (conn or db).fetchone( wallet = await (conn or db).fetchone(
""" """
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""", """,
{"key": key}, {"key": key},
BaseWallet, BaseWallet,
@@ -294,8 +300,14 @@ async def get_total_balance(conn: Connection | None = None):
return row.get("balance", 0) or 0 return row.get("balance", 0) or 0
def _clear_wallet_cache(wallet_id): def clear_wallet_id_cache(wallet_id: str):
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}") cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet: if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}") cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}") 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,3 +856,11 @@ async def m043_add_ui_customization_to_accounts(db: Connection):
Used for server side persistence of UI customization settings. Used for server side persistence of UI customization settings.
""" """
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT") 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,6 +181,7 @@ class AccountId(BaseModel):
class Account(AccountId): class Account(AccountId):
activated: bool = True
external_id: str | None = None # for external account linking external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
@@ -241,6 +242,7 @@ class Account(AccountId):
class AccountOverview(Account): class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0 transaction_count: int | None = 0
wallet_count: int | None = 0 wallet_count: int | None = 0
balance_msat: int | None = 0 balance_msat: int | None = 0
@@ -276,6 +278,7 @@ class AccountFilters(FilterModel):
class User(BaseModel): class User(BaseModel):
id: str id: str
activated: bool = True
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
email: str | None = None email: str | None = None
@@ -315,6 +318,7 @@ class RegisterUser(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20) username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: 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): class CreateUser(BaseModel):
+21
View File
@@ -3,8 +3,10 @@ from uuid import uuid4
from loguru import logger from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.db import Connection from lnbits.db import Connection
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
@@ -192,3 +194,22 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict()) editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_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 and data.invitation_code:
code = data.invitation_code.strip()
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.")
+7 -1
View File
@@ -26,7 +26,10 @@ from lnbits.core.models.users import (
UpdateAccessControlList, UpdateAccessControlList,
) )
from lnbits.core.services import create_user_account from lnbits.core.services import create_user_account
from lnbits.core.services.users import update_user_account from lnbits.core.services.users import (
check_register_activation_settings,
update_user_account,
)
from lnbits.decorators import ( from lnbits.decorators import (
access_token_payload, access_token_payload,
check_account_exists, check_account_exists,
@@ -86,6 +89,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
account = await get_account_by_username_or_email(data.username) account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@@ -363,6 +367,8 @@ async def register(data: RegisterUser) -> JSONResponse:
if data.email and not is_valid_email_address(data.email): if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
await check_register_activation_settings(data)
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
email=data.email, email=data.email,
+40 -2
View File
@@ -20,6 +20,7 @@ from lnbits.core.crud import (
update_admin_settings, update_admin_settings,
update_wallet, 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.crud.wallets import delete_wallet_by_id
from lnbits.core.models import ( from lnbits.core.models import (
AccountFilters, AccountFilters,
@@ -73,7 +74,7 @@ async def api_get_users(
summary="Get user by Id", summary="Get user by Id",
) )
async def api_get_user(user_id: str) -> User: async def api_get_user(user_id: str) -> User:
user = await get_user(user_id) user = await get_user(user_id, activated=None)
if not user: if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user return user
@@ -197,7 +198,7 @@ async def api_users_reset_password(user_id: str) -> str:
return f"reset_key_{reset_key_b64}" return f"reset_key_{reset_key_b64}"
@users_router.get( @users_router.put(
"/user/{user_id}/admin", "/user/{user_id}/admin",
dependencies=[Depends(check_super_user)], dependencies=[Depends(check_super_user)],
name="Give or revoke admin permsisions to a user", name="Give or revoke admin permsisions to a user",
@@ -220,6 +221,43 @@ 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, activated=None)
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") @users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]: async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id, deleted=None) return await get_wallets(user_id, deleted=None)
+2 -4
View File
@@ -9,6 +9,7 @@ from fastapi import (
) )
from lnbits.core.crud.wallets import ( from lnbits.core.crud.wallets import (
clear_wallet_cache,
create_wallet, create_wallet,
get_wallets_paginated, get_wallets_paginated,
) )
@@ -37,7 +38,6 @@ from lnbits.decorators import (
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import generate_filter_params_openapi from lnbits.helpers import generate_filter_params_openapi
from lnbits.utils.cache import cache
from ..crud import ( from ..crud import (
delete_wallet, delete_wallet,
@@ -134,9 +134,7 @@ async def api_reset_wallet_keys(
if not wallet or wallet.user != account_id.id: if not wallet or wallet.user != account_id.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
cache.pop(f"auth:wallet:{wallet.id}") clear_wallet_cache(wallet)
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
wallet.adminkey = uuid4().hex wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex wallet.inkey = uuid4().hex
+1
View File
@@ -261,6 +261,7 @@ async def check_account_id_exists(
account_id, account_id,
expiry=settings.auth_authentication_cache_minutes * 60, expiry=settings.auth_authentication_cache_minutes * 60,
) )
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
return account_id return account_id
+16
View File
@@ -41,6 +41,14 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[]) lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[]) lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True) 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 @property
def new_accounts_allowed(self) -> bool: def new_accounts_allowed(self) -> bool:
@@ -1188,6 +1196,11 @@ class PublicSettings(BaseModel):
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel") wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl") wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon") 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 @classmethod
def from_settings(cls, settings: Settings): def from_settings(cls, settings: Settings):
@@ -1239,6 +1252,9 @@ class PublicSettings(BaseModel):
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label, walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url, walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon, 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
+25 -1
View File
@@ -171,6 +171,7 @@ window.localisation.en = {
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled',
pay_to_enable: 'Pay To Enable', pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
@@ -178,6 +179,8 @@ window.localisation.en = {
installed: 'Installed', installed: 'Installed',
activated: 'Activated', activated: 'Activated',
deactivated: 'Deactivated', deactivated: 'Deactivated',
activate: 'Activate',
deactivate: 'Deactivate',
release_notes: 'Release Notes', release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users', activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured', featured: 'Featured',
@@ -186,6 +189,8 @@ window.localisation.en = {
only_admins_can_create_extensions: only_admins_can_create_extensions:
'Only admin accounts can create extensions', 'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version', new_version: 'New Version',
reviews_url: 'Reviews URL', reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL', reviews_url_label: 'Reviews server URL',
@@ -280,7 +285,7 @@ window.localisation.en = {
'Nip5 identifier to send notifications to', 'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers', notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc: notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to', 'List of identifiers to send notifications to.',
notifications_telegram_config: 'Telegram Configuration', notifications_telegram_config: 'Telegram Configuration',
notifications_enable_telegram: 'Enable Telegram', notifications_enable_telegram: 'Enable Telegram',
@@ -704,6 +709,25 @@ window.localisation.en = {
allowed_users_label: 'User ID', allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users', allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page', 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.', new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user', start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation', stop_user_impersonation: 'Stop User Impersonation',
+4 -3
View File
@@ -66,7 +66,7 @@ window._lnbitsApi = {
name: name name: name
}) })
}, },
register(username, email, password, password_repeat) { register(username, email, password, password_repeat, invitation_code) {
return axios({ return axios({
method: 'POST', method: 'POST',
url: '/api/v1/auth/register', url: '/api/v1/auth/register',
@@ -74,7 +74,8 @@ window._lnbitsApi = {
username, username,
email, email,
password, password,
password_repeat password_repeat,
invitation_code
} }
}) })
}, },
@@ -147,7 +148,7 @@ window._lnbitsApi = {
name: name, name: name,
wallet_type: walletType, wallet_type: walletType,
...opts ...opts
}).catch(LNbits.utils.notifyApiError) })
}, },
updateWallet(name, wallet) { updateWallet(name, wallet) {
return this.request('patch', '/api/v1/wallet', wallet.adminkey, { return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
+33 -1
View File
@@ -432,8 +432,10 @@ window.app.component('username-password', {
username: String, username: String,
password_1: String, password_1: String,
password_2: String, password_2: String,
invitationCode: String,
resetKey: String resetKey: String
}, },
data() { data() {
return { return {
oauth: [ oauth: [
@@ -445,7 +447,11 @@ window.app.component('username-password', {
username: this.userName, username: this.userName,
password: this.password_1, password: this.password_1,
passwordRepeat: this.password_2, passwordRepeat: this.password_2,
reset_key: this.resetKey reset_key: this.resetKey,
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false
} }
}, },
methods: { methods: {
@@ -458,6 +464,7 @@ window.app.component('username-password', {
this.$emit('update:userName', this.username) this.$emit('update:userName', this.username)
this.$emit('update:password_1', this.password) this.$emit('update:password_1', this.password)
this.$emit('update:password_2', this.passwordRepeat) this.$emit('update:password_2', this.passwordRepeat)
this.$emit('update:invitationCode', this.confirmationCode)
this.$emit('register') this.$emit('register')
}, },
reset() { reset() {
@@ -549,6 +556,31 @@ window.app.component('username-password', {
computed: { computed: {
showOauth() { showOauth() {
return this.oauth.some(m => this.authMethods.includes(m)) 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
console.log('### disableRegister', {
usernameOK,
passwordOK,
passwordsMatch,
codeOk
})
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() {} created() {}
@@ -4,7 +4,9 @@ window.app.component('lnbits-admin-users', {
data() { data() {
return { return {
formAddUser: '', formAddUser: '',
formAddAdmin: '' formAddAdmin: '',
formAddActivationCode: '',
showReusableActivationCode: false
} }
}, },
methods: { methods: {
@@ -31,6 +33,24 @@ window.app.component('lnbits-admin-users', {
removeAdminUser(user) { removeAdminUser(user) {
let admin_users = this.formData.lnbits_admin_users let admin_users = this.formData.lnbits_admin_users
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user) 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,6 +97,7 @@ window.app.component('lnbits-wallet-new', {
this.g.lastWalletId = res.data.id this.g.lastWalletId = res.data.id
this.$router.push(`/wallet/${res.data.id}`) this.$router.push(`/wallet/${res.data.id}`)
}) })
.catch(LNbits.utils.notifyApiError)
} }
}, },
created() { created() {
+4 -1
View File
@@ -11,6 +11,7 @@ window.PageHome = {
email: '', email: '',
password: '', password: '',
passwordRepeat: '', passwordRepeat: '',
invitationCode: '',
walletName: '', walletName: '',
signup: false signup: false
} }
@@ -40,6 +41,7 @@ window.PageHome = {
this.username = null this.username = null
this.password = null this.password = null
this.passwordRepeat = null this.passwordRepeat = null
this.invitationCode = null
this.authAction = 'register' this.authAction = 'register'
this.authMethod = authMethod this.authMethod = authMethod
@@ -51,7 +53,8 @@ window.PageHome = {
this.username, this.username,
this.email, this.email,
this.password, this.password,
this.passwordRepeat this.passwordRepeat,
this.invitationCode
) )
this.refreshAuthUser() this.refreshAuthUser()
} catch (e) { } catch (e) {
+17 -4
View File
@@ -70,10 +70,10 @@ window.PageUsers = {
usersTable: { usersTable: {
columns: [ columns: [
{ {
name: 'admin', name: 'activated',
align: 'left', align: 'left',
label: 'Admin', label: this.$t('activated'),
field: 'admin', field: 'activated',
sortable: false sortable: false
}, },
{ {
@@ -401,7 +401,7 @@ window.PageUsers = {
toggleAdmin(userId) { toggleAdmin(userId) {
LNbits.api LNbits.api
.request('GET', `/users/api/v1/user/${userId}/admin`) .request('PUT', `/users/api/v1/user/${userId}/admin`)
.then(() => { .then(() => {
this.fetchUsers() this.fetchUsers()
Quasar.Notify.create({ Quasar.Notify.create({
@@ -412,6 +412,19 @@ window.PageUsers = {
}) })
.catch(LNbits.utils.notifyApiError) .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) { async showAccountPage(user_id) {
this.activeUser.showPassword = false this.activeUser.showPassword = false
this.activeUser.showUserId = false this.activeUser.showUserId = false
+80 -6
View File
@@ -814,16 +814,90 @@ include('components/lnbits-error.vue') %}
type="password" type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]" :rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
></q-input> ></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"> <div class="row justify-end">
<q-btn <q-btn
unelevated unelevated
color="primary" color="primary"
:disable=" :disable="disableRegister"
!password ||
!passwordRepeat ||
!username ||
password !== passwordRepeat
"
type="submit" type="submit"
class="full-width" class="full-width"
:label="$t('create_account')" :label="$t('create_account')"
+234 -3
View File
@@ -30,7 +30,6 @@
> >
</q-chip> </q-chip>
</div> </div>
<br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('allowed_users')"></span></p> <p><span v-text="$t('allowed_users')"></span></p>
@@ -57,7 +56,10 @@
> >
</q-chip> </q-chip>
</div> </div>
<br /> </div>
</div>
<div class="row">
<div class="col-12 col-md-6 q-mt-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label v-text="$t('allow_creation_user')"></q-item-label> <q-item-label v-text="$t('allow_creation_user')"></q-item-label>
@@ -76,8 +78,237 @@
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
<br /> </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>
</div> </div>
</div> </div>
</q-card-section> </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> </template>
+1
View File
@@ -63,6 +63,7 @@
v-model:user-name="username" v-model:user-name="username"
v-model:password_1="password" v-model:password_1="password"
v-model:password_2="passwordRepeat" v-model:password_2="passwordRepeat"
v-model:invitation-code="invitationCode"
v-model:reset-key="reset_key" v-model:reset-key="reset_key"
@login="login" @login="login"
@register="register" @register="register"
+33 -5
View File
@@ -218,6 +218,26 @@
:label="$t('update_account')" :label="$t('update_account')"
class="q-ml-md" class="q-ml-md"
></q-btn> ></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 <q-btn
v-else v-else
@click="createUser()" @click="createUser()"
@@ -590,9 +610,9 @@
<q-btn <q-btn
@click="showAccountPage(props.row.id)" @click="showAccountPage(props.row.id)"
round round
icon="edit" :icon="props.row.is_admin ? 'admin_panel_settings' : 'edit'"
size="sm" size="sm"
color="secondary" :color="props.row.is_admin ? 'primary' : 'secondary'"
class="q-ml-xs" class="q-ml-xs"
> >
<q-tooltip> <q-tooltip>
@@ -605,10 +625,18 @@
size="xs" size="xs"
v-if="!props.row.is_super_user" v-if="!props.row.is_super_user"
color="secondary" color="secondary"
v-model="props.row.is_admin" v-model="props.row.activated"
@update:model-value="toggleAdmin(props.row.id)" @update:model-value="toggleUserActivated(props.row.id)"
> >
<q-tooltip>Toggle Admin</q-tooltip> <q-tooltip
><span
v-text="
props.row.activated
? $t('deactivate')
: $t('activate')
"
></span
></q-tooltip>
</q-toggle> </q-toggle>
<q-btn <q-btn
round round
+126
View File
@@ -1,11 +1,13 @@
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
import jwt
import pytest import pytest
import shortuuid import shortuuid
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.core.crud.wallets import get_wallets 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.models.users import Account, User
from lnbits.core.services.users import create_user_account from lnbits.core.services.users import create_user_account
from lnbits.settings import Settings from lnbits.settings import Settings
@@ -610,3 +612,127 @@ 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) undeleted_wallet = next((w for w in wallets if w.id == wallet_id), None)
assert undeleted_wallet is not None assert undeleted_wallet is not None
assert undeleted_wallet.deleted is False 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