[feat] User invitation code (#3761)

This commit is contained in:
Vlad Stan
2026-02-25 07:54:07 +01:00
committed by dni ⚡
parent eae1be1db8
commit e286546b63
16 changed files with 586 additions and 18 deletions
+1
View File
@@ -318,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):
+24
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,25 @@ 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:
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.")
+6 -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,
@@ -366,6 +369,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,
+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
+21 -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',
@@ -284,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',
@@ -708,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',
+3 -2
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
} }
}) })
}, },
+27 -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,25 @@ 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
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
)
} }
} }
}) })
+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) {
+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"
+142
View File
@@ -297,6 +297,148 @@ async def test_register_ok(http_client: AsyncClient):
), f"Expected 1 default wallet, not {len(user.wallets)}." ), 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 @pytest.mark.anyio
async def test_register_email_twice(http_client: AsyncClient): async def test_register_email_twice(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8] tiny_id = shortuuid.uuid()[:8]
+4
View File
@@ -341,3 +341,7 @@ def _settings_cleanup(settings: Settings):
settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100 settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200 settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
settings.stripe_limits = FiatProviderLimits() 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 = []