From fadad38c994090a72e5566a74bca968deedcaeda Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 29 Jan 2026 13:02:02 +0200 Subject: [PATCH] feat: basic account activate/deactivate --- lnbits/core/crud/__init__.py | 4 ++ lnbits/core/crud/users.py | 105 ++++++++++++++++++++++++------- lnbits/core/migrations.py | 8 +++ lnbits/core/models/users.py | 1 + lnbits/core/views/auth_api.py | 7 ++- lnbits/core/views/user_api.py | 40 +++++++++++- lnbits/static/i18n/en.js | 2 + lnbits/static/js/pages/users.js | 21 +++++-- lnbits/templates/pages/users.vue | 14 ++++- 9 files changed, 171 insertions(+), 31 deletions(-) diff --git a/lnbits/core/crud/__init__.py b/lnbits/core/crud/__init__.py index 2108d23d5..708009db9 100644 --- a/lnbits/core/crud/__init__.py +++ b/lnbits/core/crud/__init__.py @@ -61,7 +61,9 @@ from .users import ( get_user, get_user_access_control_lists, get_user_from_account, + is_account_activated, update_account, + update_account_activation, ) from .wallets import ( create_wallet, @@ -144,11 +146,13 @@ __all__ = [ "get_wallets", "get_webpush_subscription", "get_webpush_subscriptions_for_user", + "is_account_activated", "is_internal_status_success", "mark_webhook_sent", "remove_deleted_wallets", "reset_core_settings", "update_account", + "update_account_activation", "update_admin_settings", "update_installed_extension", "update_installed_extension_state", diff --git a/lnbits/core/crud/users.py b/lnbits/core/crud/users.py index ec7ed3d6e..450617d02 100644 --- a/lnbits/core/crud/users.py +++ b/lnbits/core/crud/users.py @@ -36,6 +36,23 @@ async def update_account(account: Account, conn: Connection | None = None) -> Ac return account +async def update_account_activation( + user_id: str, activated: bool, conn: Connection | None = None +) -> None: + await (conn or db).execute( + f""" + UPDATE accounts + SET activated = :activated, updated_at = {db.timestamp_placeholder('now')} + WHERE id = :user_id + """, # noqa: S608 + { + "activated": activated, + "now": int(time()), + "user_id": user_id, + }, + ) + + async def delete_account(user_id: str, conn: Connection | None = None) -> None: await (conn or db).execute( "DELETE from accounts WHERE id = :user", @@ -69,6 +86,7 @@ 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, @@ -93,16 +111,33 @@ 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: return None + return await (conn or db).fetchone( - "SELECT * FROM accounts WHERE id = :id", - {"id": user_id}, + f""" + SELECT * FROM accounts + WHERE id = :id {_activated_clause(activated)} + """, # noqa: S608 + {"id": user_id, "activated": activated}, Account, ) +async def is_account_activated( + user_id: str, conn: Connection | None = None +) -> bool | None: + result = await (conn or db).execute( + "SELECT activated FROM accounts WHERE id = :id", + {"id": user_id}, + ) + row = result.mappings().first() + return row.get("activated", False) if row is not None else None + + async def delete_accounts_no_wallets( time_delta: int, conn: Connection | None = None, @@ -124,55 +159,72 @@ async def delete_accounts_no_wallets( async def get_account_by_username( - username: str, conn: Connection | None = None + username: str, activated: bool | None = True, 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", - {"username": username.lower()}, + f""" + SELECT * FROM accounts + WHERE LOWER(username) = :username {_activated_clause(activated)} + """, # noqa: S608 + {"username": username.lower(), "activated": activated}, Account, ) async def get_account_by_pubkey( - pubkey: str, conn: Connection | None = None + pubkey: str, activated: bool | None = True, conn: Connection | None = None ) -> Account | None: return await (conn or db).fetchone( - "SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey", - {"pubkey": pubkey.lower()}, + f""" + SELECT * FROM accounts + WHERE LOWER(pubkey) = :pubkey {_activated_clause(activated)} + """, # noqa: S608 + {"pubkey": pubkey.lower(), "activated": activated}, Account, ) async def get_account_by_email( - email: str, conn: Connection | None = None + email: str, activated: bool | None = True, 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", - {"email": email.lower()}, + f""" + SELECT * FROM accounts + WHERE LOWER(email) = :email {_activated_clause(activated)} + """, # noqa: S608 + {"email": email.lower(), "activated": activated}, Account, ) async def get_account_by_username_or_email( - username_or_email: str, conn: Connection | None = None + username_or_email: str, + activated: bool | None = True, + conn: Connection | None = None, ) -> Account | None: + return await (conn or db).fetchone( - """ + f""" SELECT * FROM accounts - WHERE LOWER(email) = :value or LOWER(username) = :value - """, - {"value": username_or_email.lower()}, + WHERE (LOWER(email) = :value or LOWER(username) = :value) + {_activated_clause(activated)} + """, # noqa: S608 + {"value": username_or_email.lower(), "activated": activated}, 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: - account = await get_account(user_id, conn=conn) + account = await get_account(user_id, activated=activated, conn=conn) if not account: return None return await get_user_from_account(account, conn=conn) @@ -216,12 +268,21 @@ async def update_user_access_control_list( async def get_user_access_control_lists( - user_id: str, conn: Connection | None = None + user_id: str, activated: bool | None = True, conn: Connection | None = None ) -> UserAcls: user_acls = await (conn or db).fetchone( - "SELECT id, access_control_list FROM accounts WHERE id = :id", - {"id": user_id}, + f""" + SELECT id, access_control_list FROM accounts + WHERE id = :user_id {_activated_clause(activated)} + """, # noqa: S608 + {"user_id": user_id, "activated": activated}, UserAcls, ) return user_acls or UserAcls(id=user_id) + + +def _activated_clause(activated: bool | None) -> str: + if activated is None: + return "" + return "AND activated = :activated" diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index 9d0ccf21a..19aa2b9a2 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -856,3 +856,11 @@ 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") diff --git a/lnbits/core/models/users.py b/lnbits/core/models/users.py index dac211b4b..576b97482 100644 --- a/lnbits/core/models/users.py +++ b/lnbits/core/models/users.py @@ -241,6 +241,7 @@ class Account(AccountId): class AccountOverview(Account): + activated: bool transaction_count: int | None = 0 wallet_count: int | None = 0 balance_msat: int | None = 0 diff --git a/lnbits/core/views/auth_api.py b/lnbits/core/views/auth_api.py index a6ac7e5d0..c416ba389 100644 --- a/lnbits/core/views/auth_api.py +++ b/lnbits/core/views/auth_api.py @@ -51,6 +51,7 @@ from ..crud import ( get_account_by_username, get_account_by_username_or_email, get_user_from_account, + is_account_activated, update_account, ) from ..models import ( @@ -83,9 +84,13 @@ async def login(data: LoginUsernamePassword) -> JSONResponse: raise HTTPException( HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed." ) - account = await get_account_by_username_or_email(data.username) + account = await get_account_by_username_or_email(data.username, activated=None) if not account or not account.verify_password(data.password): raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") + + is_activated = await is_account_activated(account.id) + if is_activated is False: + raise HTTPException(HTTPStatus.FORBIDDEN, "Account is not activated.") return _auth_success_response(account.username, account.id, account.email) diff --git a/lnbits/core/views/user_api.py b/lnbits/core/views/user_api.py index 3aba11e8e..3f125231f 100644 --- a/lnbits/core/views/user_api.py +++ b/lnbits/core/views/user_api.py @@ -17,6 +17,8 @@ from lnbits.core.crud import ( get_user, get_wallet, get_wallets, + is_account_activated, + update_account_activation, update_admin_settings, update_wallet, ) @@ -197,7 +199,7 @@ async def api_users_reset_password(user_id: str) -> str: return f"reset_key_{reset_key_b64}" -@users_router.get( +@users_router.put( "/user/{user_id}/admin", dependencies=[Depends(check_super_user)], name="Give or revoke admin permsisions to a user", @@ -220,6 +222,42 @@ 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, 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 == account.id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Users cannot deactivate themselves.", + ) + + if settings.is_admin_user(user_id): + settings.lnbits_admin_users.remove(user_id) + + is_activated = await is_account_activated(user_id) + + if is_activated is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="User not found.", + ) + await update_account_activation(user_id, not is_activated) + + return SimpleStatus( + success=True, + message=f"User {'activated' if not is_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) diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index f4b18431f..4197de371 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -178,6 +178,8 @@ 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', diff --git a/lnbits/static/js/pages/users.js b/lnbits/static/js/pages/users.js index 5cb38d765..d33f9ec81 100644 --- a/lnbits/static/js/pages/users.js +++ b/lnbits/static/js/pages/users.js @@ -70,10 +70,10 @@ window.PageUsers = { usersTable: { columns: [ { - name: 'admin', + name: 'activated', align: 'left', - label: 'Admin', - field: 'admin', + label: this.$t('activated'), + field: 'activated', sortable: false }, { @@ -401,7 +401,7 @@ window.PageUsers = { toggleAdmin(userId) { LNbits.api - .request('GET', `/users/api/v1/user/${userId}/admin`) + .request('PUT', `/users/api/v1/user/${userId}/admin`) .then(() => { this.fetchUsers() Quasar.Notify.create({ @@ -412,6 +412,19 @@ window.PageUsers = { }) .catch(LNbits.utils.notifyApiError) }, + toggleUserActivated(userId) { + LNbits.api + .request('PUT', `/users/api/v1/user/${userId}/activate`) + .then(() => { + this.fetchUsers() + Quasar.Notify.create({ + type: 'positive', + message: 'Toggled user activation!', + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + }, async showAccountPage(user_id) { this.activeUser.showPassword = false this.activeUser.showUserId = false diff --git a/lnbits/templates/pages/users.vue b/lnbits/templates/pages/users.vue index 1a46de215..8e07e767a 100644 --- a/lnbits/templates/pages/users.vue +++ b/lnbits/templates/pages/users.vue @@ -605,10 +605,18 @@ size="xs" v-if="!props.row.is_super_user" color="secondary" - v-model="props.row.is_admin" - @update:model-value="toggleAdmin(props.row.id)" + v-model="props.row.activated" + @update:model-value="toggleUserActivated(props.row.id)" > - Toggle Admin +