feat: basic account activate/deactivate

This commit is contained in:
Vlad Stan
2026-02-05 12:21:01 +02:00
parent 2fe7ab4f83
commit 54fdb0948c
6 changed files with 92 additions and 81 deletions
+4
View File
@@ -61,7 +61,9 @@ from .users import (
get_user, get_user,
get_user_access_control_lists, get_user_access_control_lists,
get_user_from_account, get_user_from_account,
is_account_activated,
update_account, update_account,
update_account_activation,
) )
from .wallets import ( from .wallets import (
create_wallet, create_wallet,
@@ -144,11 +146,13 @@ __all__ = [
"get_wallets", "get_wallets",
"get_webpush_subscription", "get_webpush_subscription",
"get_webpush_subscriptions_for_user", "get_webpush_subscriptions_for_user",
"is_account_activated",
"is_internal_status_success", "is_internal_status_success",
"mark_webhook_sent", "mark_webhook_sent",
"remove_deleted_wallets", "remove_deleted_wallets",
"reset_core_settings", "reset_core_settings",
"update_account", "update_account",
"update_account_activation",
"update_admin_settings", "update_admin_settings",
"update_installed_extension", "update_installed_extension",
"update_installed_extension_state", "update_installed_extension_state",
+66 -59
View File
@@ -4,12 +4,10 @@ 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 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.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,
@@ -38,12 +36,28 @@ async def update_account(account: Account, conn: Connection | None = None) -> Ac
return account 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: async def delete_account(user_id: str, conn: Connection | None = None) -> None:
await (conn or db).execute( await (conn or db).execute(
"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(
@@ -98,21 +112,32 @@ async def get_accounts(
async def get_account( async def get_account(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(user_id) == 0: if len(user_id) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" f"""
SELECT * FROM accounts SELECT * FROM accounts
WHERE id = :id AND (activated = true OR activated = :activated) WHERE id = :id {_activated_clause(activated)}
""", """, # noqa: S608
{"id": user_id, "activated": active_only}, {"id": user_id, "activated": activated},
Account, 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( async def delete_accounts_no_wallets(
time_delta: int, time_delta: int,
conn: Connection | None = None, conn: Connection | None = None,
@@ -134,79 +159,72 @@ async def delete_accounts_no_wallets(
async def get_account_by_username( async def get_account_by_username(
username: str, active_only: bool = True, conn: Connection | None = None username: str, activated: bool | None = 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(
""" f"""
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(username) = :username {_activated_clause(activated)}
LOWER(username) = :username """, # noqa: S608
AND (activated = true OR activated = :activated) {"username": username.lower(), "activated": activated},
""",
{"username": username.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_pubkey( async def get_account_by_pubkey(
pubkey: str, active_only: bool = True, 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(
""" f"""
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(pubkey) = :pubkey {_activated_clause(activated)}
LOWER(pubkey) = :pubkey """, # noqa: S608
AND (activated = true OR activated = :activated) {"pubkey": pubkey.lower(), "activated": activated},
""",
{"pubkey": pubkey.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_email( async def get_account_by_email(
email: str, active_only: bool = True, conn: Connection | None = None email: str, activated: bool | None = 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(
""" f"""
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(email) = :email {_activated_clause(activated)}
LOWER(email) = :email """, # noqa: S608
AND (activated = true OR activated = :activated) {"email": email.lower(), "activated": activated},
""",
{"email": email.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_username_or_email( async def get_account_by_username_or_email(
username_or_email: str, username_or_email: str,
active_only: bool = True, activated: bool | None = True,
conn: Connection | None = None, conn: Connection | None = None,
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" f"""
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE (LOWER(email) = :value or LOWER(username) = :value)
(LOWER(email) = :value or LOWER(username) = :value) {_activated_clause(activated)}
AND (activated = true OR activated = :activated) """, # noqa: S608
""", {"value": username_or_email.lower(), "activated": activated},
{"value": username_or_email.lower(), "activated": active_only},
Account, Account,
) )
async def get_user( async def get_user(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> User | 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, active_only, 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)
@@ -225,7 +243,6 @@ 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,
@@ -251,31 +268,21 @@ async def update_user_access_control_list(
async def get_user_access_control_lists( async def get_user_access_control_lists(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> UserAcls: ) -> UserAcls:
user_acls = await (conn or db).fetchone( user_acls = await (conn or db).fetchone(
""" f"""
SELECT id, access_control_list FROM accounts SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND (activated = true OR activated = :activated) WHERE id = :user_id {_activated_clause(activated)}
""", """, # noqa: S608
{"user_id": user_id, "activated": active_only}, {"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): def _activated_clause(activated: bool | None) -> str:
user = await get_user(user_id, active_only=True) if activated is None:
if user: return ""
clear_user_cache(user) return "AND activated = :activated"
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)
+1 -3
View File
@@ -181,7 +181,6 @@ 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
@@ -242,7 +241,7 @@ class Account(AccountId):
class AccountOverview(Account): class AccountOverview(Account):
activated: bool = True activated: bool
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
@@ -278,7 +277,6 @@ 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
+8 -6
View File
@@ -51,6 +51,7 @@ from ..crud import (
get_account_by_username, get_account_by_username,
get_account_by_username_or_email, get_account_by_username_or_email,
get_user_from_account, get_user_from_account,
is_account_activated,
update_account, update_account,
) )
from ..models import ( from ..models import (
@@ -83,10 +84,13 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
raise HTTPException( raise HTTPException(
HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed." 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): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") 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) return _auth_success_response(account.username, account.id, account.email)
@@ -95,7 +99,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98): if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request) 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: if not account:
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
@@ -103,8 +107,6 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"), extra=UserExtra(provider="nostr"),
) )
await create_user_account(account) 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) return _auth_success_response(account.username or "", account.id, account.email)
@@ -360,7 +362,7 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username): if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid 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.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email): if data.email and not is_valid_email_address(data.email):
@@ -530,7 +532,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet" 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 verified_user_id:
if account: if account:
+11 -11
View File
@@ -17,10 +17,11 @@ from lnbits.core.crud import (
get_user, get_user,
get_wallet, get_wallet,
get_wallets, get_wallets,
is_account_activated,
update_account_activation,
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,
@@ -74,7 +75,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, active_only=False) user = await get_user(user_id)
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
@@ -226,35 +227,34 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
name="Activate or deactivate a user", name="Activate or deactivate a user",
) )
async def api_users_toggle_activated( async def api_users_toggle_activated(
user_id: str, admin_account: Account = Depends(check_admin) user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus: ) -> SimpleStatus:
if user_id == settings.super_user: if user_id == settings.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot deactivate super user.", detail="Cannot deactivate super user.",
) )
if user_id == admin_account.id: if user_id == account.id:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="You cannot deactivate yourself.", detail="Users cannot deactivate themselves.",
) )
if settings.is_admin_user(user_id): if settings.is_admin_user(user_id):
settings.lnbits_admin_users.remove(user_id) settings.lnbits_admin_users.remove(user_id)
user_account = await get_account(user_id, active_only=False) is_activated = await is_account_activated(user_id)
if not user_account:
if is_activated is None:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail="User not found.", detail="User not found.",
) )
user_account.activated = not user_account.activated await update_account_activation(user_id, not is_activated)
await update_account(user_account)
await clear_user_id_cache(user_id)
return SimpleStatus( return SimpleStatus(
success=True, success=True,
message=f"User {'activated' if user_account.activated else 'deactivated'}.", message=f"User {'activated' if not is_activated else 'deactivated'}.",
) )
+2 -2
View File
@@ -415,11 +415,11 @@ window.PageUsers = {
toggleUserActivated(userId) { toggleUserActivated(userId) {
LNbits.api LNbits.api
.request('PUT', `/users/api/v1/user/${userId}/activate`) .request('PUT', `/users/api/v1/user/${userId}/activate`)
.then(res => { .then(() => {
this.fetchUsers() this.fetchUsers()
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: res.data.message, message: 'Toggled user activation!',
icon: null icon: null
}) })
}) })