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
+