Implement email verification for user signup

Added secure token generation and hashing for email verification, refactored backend signup flow to store token hashes, and send confirmation emails using a new email message utility. Updated frontend server settings UI to allow configuration of signup, admin approval, and email confirmation requirements. Adjusted related backend and frontend logic and translations for improved clarity and consistency.
This commit is contained in:
João Vitória Silva
2025-09-06 21:26:14 +01:00
parent c74d89f7fb
commit ea6b7b7ae8
10 changed files with 323 additions and 139 deletions

View File

@@ -1,4 +1,4 @@
import os
import os, secrets, hashlib
import apprise
from typing import List
@@ -216,4 +216,20 @@ def get_email_service():
return email_service
def generate_token_and_hash() -> tuple[str, str]:
"""
Generates a secure random token and its SHA-256 hash.
Returns:
tuple[str, str]: A tuple containing the generated token and its SHA-256 hash.
"""
# Generate a random 32-byte token
token = secrets.token_urlsafe(32)
# Create a hash of the token for database storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
email_service = AppriseService()

View File

@@ -1,6 +1,3 @@
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from fastapi import (
HTTPException,
@@ -22,34 +19,6 @@ import core.logger as core_logger
from core.database import SessionLocal
def generate_password_reset_token() -> tuple[str, str]:
"""
Generate a URL-safe password reset token and its SHA-256 hash for storage.
Returns:
tuple[str, str]: A tuple (token, token_hash) where:
- token: a URL-safe, cryptographically secure random token suitable for
inclusion in password reset links (this raw token is intended to be
sent to the user).
- token_hash: the hexadecimal SHA-256 hash of the token, suitable for
storing in a database instead of the raw token.
Notes:
- Do not store or log the raw token; store only the hash (token_hash).
- When validating a presented token, compute its SHA-256 hex digest and
compare it to the stored token_hash using a constant-time comparison to
mitigate timing attacks (e.g., secrets.compare_digest).
- Consider associating an expiration timestamp and single-use semantics with
the token to limit its validity window.
- Token generation relies on the `secrets` module for cryptographic randomness.
"""
# Generate a random 32-byte token
token = secrets.token_urlsafe(32)
# Create a hash of the token for database storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
def create_password_reset_token(user_id: int, db: Session) -> str:
"""
Create and persist a password reset token for a user and return the plain token.
@@ -93,7 +62,7 @@ def create_password_reset_token(user_id: int, db: Session) -> str:
# Send `token` to the user's email. Do not store the plaintext token in persistent storage.
"""
# Generate token and hash
token, token_hash = generate_password_reset_token()
token, token_hash = core_apprise.generate_token_and_hash()
# Create token object
reset_token = password_reset_tokens_schema.PasswordResetToken(

View File

@@ -0,0 +1,100 @@
import core.apprise as core_apprise
def get_signup_confirmation_email_en(
user_name: str, signup_link: str, email_service: core_apprise.AppriseService
) -> tuple:
"""
Return the subject, HTML body, and plain-text body for an English sign-up confirmation email.
Args:
user_name (str): The recipient's display name inserted into the greeting.
signup_link (str): The URL the user will follow to confirm their sign-up; inserted into the CTA button
and included as a plain link for clients that do not render the button.
email_service (core_apprise.AppriseService): Notification service instance used to obtain service
metadata (e.g., `frontend_host`) for the email footer.
Returns:
tuple[str, str, str]: A 3-tuple containing:
- subject: The email subject line.
- html_content: The full HTML email content (string) including inline styles, logo, a prominent
"Confirm Account" button linking to `signup_link`, a security notice about a 24-hour expiry, and
a footer referencing `email_service.frontend_host`.
- text_content: A plain-text alternative suitable for clients that do not render HTML, containing
the greeting, confirmation instructions, the raw `signup_link`, expiry notice, and sign-off.
Notes:
- The function only constructs and returns strings; it does not send emails or perform network I/O.
- Calling code should ensure `signup_link` and `user_name` are properly validated/sanitized as needed.
- The HTML is crafted with inline styles for broad email-client compatibility.
"""
subject = "Endurain - Confirm your account"
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{subject}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
alt="Endurain logo" style="height: 32px; width: auto;">
<span>Endurain</span>
</div>
<h3 style="margin: 0;">Confirm your account</h3>
</div>
<div style="margin-bottom: 30px;">
<p>Hi {user_name},</p>
<p>Thank you for signing up for Endurain! Please confirm your account by clicking the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{signup_link}" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Confirm Account</a>
</div>
<!--<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Security notice:</strong> This confirmation link will expire in 24 hours.
</div>-->
<p>If you didnt create an Endurain account, please ignore this email.</p>
<p>If the button above doesnt work, you can copy and paste the following link into your browser:</p>
<p style="word-break: break-all; color: #198754;">{signup_link}</p>
</div>
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<p>Best regards,<br>The Endurain team</p>
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
Source code at: <a style="color: #198754;"
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
</div>
</div>
</body>
</html>
""".strip()
# Create text version
text_content = f"""
Hi {user_name},
Thank you for signing up for Endurain!
Please confirm your account by clicking the following link:
{signup_link}
This confirmation link will expire in 24 hours.
If you didnt create an Endurain account, please ignore this email.
Best regards,
The Endurain team
""".strip()
return subject, html_content, text_content

View File

@@ -1,3 +1,4 @@
import email
from typing import Annotated, Callable
from fastapi import (
@@ -16,7 +17,7 @@ import session.utils as session_utils
import session.security as session_security
import session.crud as session_crud
import session.schema as session_schema
import session.constants as session_constants
import session.email_messages as session_email_messages
import users.user.crud as users_crud
import users.user.utils as users_utils
@@ -30,6 +31,7 @@ import profile.utils as profile_utils
import server_settings.crud as server_settings_crud
import core.database as core_database
import core.apprise as core_apprise
# Define the API router
router = APIRouter()
@@ -116,12 +118,15 @@ async def complete_login(
@router.post("/signup", status_code=201)
async def signup(
user: users_schema.UserSignup,
email_service: Annotated[
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""Public endpoint for user sign-up"""
# Get server settings to check if signup is enabled
server_settings = server_settings_crud.get_server_settings(db)
if not server_settings:
@@ -131,16 +136,24 @@ async def signup(
)
# Check if signup is enabled
users_utils.check_user_can_signup(server_settings)
if not server_settings.signup_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User sign-up is not enabled on this server",
)
# Generate email verification token if needed
email_verification_token = None
email_verification_token_hash = None
if server_settings.signup_require_email_verification:
email_verification_token = users_utils.generate_email_verification_token()
# Generate token and hash
email_verification_token, email_verification_token_hash = (
core_apprise.generate_token_and_hash()
)
# Create the user in the database
created_user = users_crud.create_signup_user(
user, email_verification_token, server_settings, db
user, email_verification_token_hash, server_settings, db
)
# Create the user integrations in the database
@@ -156,27 +169,37 @@ async def signup(
user_default_gear_crud.create_user_default_gear(created_user.id, db)
# Return appropriate response based on server configuration
response_data = {"message": "User created successfully"}
response_data = {"message": "User created successfully."}
if server_settings.signup_require_email_verification:
# Generate sign-up link
sign_up_link = f"{email_service.frontend_host}/verify-email?token={email_verification_token}"
# use default email message in English
subject, html_content, text_content = (
session_email_messages.get_signup_confirmation_email_en(
user.name, sign_up_link, email_service
)
)
await email_service.send_email(
to_emails=[user.email],
subject=subject,
html_content=html_content,
text_content=text_content,
)
response_data["message"] = (
"User created successfully. Please check your email for verification instructions."
response_data["message"] + " Email sent with verification instructions."
)
response_data["email_verification_required"] = True
# TODO: Send verification email here
if server_settings.signup_require_admin_approval:
response_data["message"] = (
"User created successfully. Account is pending admin approval."
response_data["message"] + " Account is pending admin approval."
)
response_data["admin_approval_required"] = True
if (
not server_settings.signup_require_email_verification
and not server_settings.signup_require_admin_approval
):
response_data["message"] = "User created successfully. You can now log in."
response_data["message"] = response_data["message"] + " You can now log in."
return response_data

View File

@@ -548,7 +548,7 @@ def disable_user_mfa(user_id: int, db: Session):
Returns:
None
Raises:
HTTPException:
HTTPException:
- 404 Not Found if the user does not exist.
- 500 Internal Server Error for any other failure; in this case the
transaction is rolled back and the error is logged.
@@ -568,7 +568,7 @@ def disable_user_mfa(user_id: int, db: Session):
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
db_user.mfa_enabled = False
db_user.mfa_secret = None
db.commit()
@@ -582,14 +582,35 @@ def disable_user_mfa(user_id: int, db: Session):
) from err
def create_signup_user(user: users_schema.UserSignup, email_verification_token: str, server_settings, db: Session):
"""Create a new user via sign-up with appropriate verification and approval settings"""
def create_signup_user(
user: users_schema.UserSignup,
email_verification_token: str,
server_settings,
db: Session,
):
"""
Creates a new user during the signup process, handling email verification and admin approval requirements.
Args:
user (users_schema.UserSignup): The user signup data containing user details.
email_verification_token (str): Token for email verification, if required.
server_settings: Server configuration settings that determine signup requirements.
db (Session): SQLAlchemy database session.
Returns:
users_models.User: The newly created user object.
Raises:
HTTPException:
- 409 Conflict if the email or username is not unique.
- 500 Internal Server Error for any other exceptions.
"""
try:
# Determine user status based on server settings
active = True
email_verified = False
pending_admin_approval = False
if server_settings.signup_require_email_verification:
email_verified = False
active = False # Inactive until email verified
@@ -599,10 +620,13 @@ def create_signup_user(user: users_schema.UserSignup, email_verification_token:
active = False # Inactive until approved
# If both email verification and admin approval are disabled, user is immediately active
if not server_settings.signup_require_email_verification and not server_settings.signup_require_admin_approval:
if (
not server_settings.signup_require_email_verification
and not server_settings.signup_require_admin_approval
):
active = True
email_verified = True
# Create a new user
db_user = users_models.User(
name=user.name,
@@ -619,7 +643,11 @@ def create_signup_user(user: users_schema.UserSignup, email_verification_token:
first_day_of_week=user.first_day_of_week,
currency=user.currency,
email_verified=email_verified,
email_verification_token=email_verification_token if server_settings.signup_require_email_verification else None,
email_verification_token=(
email_verification_token
if server_settings.signup_require_email_verification
else None
),
pending_admin_approval=pending_admin_approval,
password=session_security.hash_password(user.password),
)
@@ -645,7 +673,9 @@ def create_signup_user(user: users_schema.UserSignup, email_verification_token:
db.rollback()
# Log the exception
core_logger.print_to_log(f"Error in create_signup_user: {err}", "error", exc=err)
core_logger.print_to_log(
f"Error in create_signup_user: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
@@ -673,7 +703,7 @@ def verify_user_email(token: str, db: Session):
# Mark email as verified and remove token
db_user.email_verified = True
db_user.email_verification_token = None
# If not pending admin approval, activate the user
if not db_user.pending_admin_approval:
db_user.active = True

View File

@@ -92,9 +92,9 @@ class User(Base):
comment="Whether the user's email address has been verified (true - yes, false - no)",
)
email_verification_token = Column(
String(length=255),
String(length=128),
nullable=True,
comment="Token for email verification",
comment="Token hash for email verification",
)
pending_admin_approval = Column(
Boolean,

View File

@@ -17,6 +17,16 @@ import core.config as core_config
def check_user_is_active(user: users_schema.User) -> None:
"""
Checks if the given user is active.
Raises:
HTTPException: If the user is not active, raises an HTTP 403 Forbidden exception
with a detail message "Inactive user" and a "WWW-Authenticate" header.
Args:
user (users_schema.User): The user object to check.
"""
if not user.active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -26,6 +36,18 @@ def check_user_is_active(user: users_schema.User) -> None:
def delete_user_photo_filesystem(user_id: int):
"""
Deletes all photo files associated with a user from the filesystem.
This function searches for files in the directory specified by `core_config.USER_IMAGES_DIR`
that match the given `user_id` with any file extension, and removes them.
Args:
user_id (int): The ID of the user whose photo files should be deleted.
Returns:
None
"""
# Define the pattern to match files with the specified name regardless of the extension
folder = core_config.USER_IMAGES_DIR
file = f"{user_id}.*"
@@ -40,11 +62,39 @@ def delete_user_photo_filesystem(user_id: int):
def format_user_birthdate(user):
user.birthdate = user.birthdate if isinstance(user.birthdate, str) else user.birthdate.isoformat() if user.birthdate else None
"""
Formats the birthdate attribute of a user object to an ISO 8601 string if it is a date/datetime object.
If the birthdate is already a string or None, it remains unchanged.
Args:
user: An object with a 'birthdate' attribute, which can be a string, date/datetime object, or None.
Returns:
The user object with the 'birthdate' attribute formatted as an ISO 8601 string, string, or None.
"""
user.birthdate = (
user.birthdate
if isinstance(user.birthdate, str)
else user.birthdate.isoformat() if user.birthdate else None
)
return user
async def save_user_image(user_id: int, file: UploadFile, db: Session):
"""
Saves a user's image to the server and updates the user's photo path in the database.
Args:
user_id (int): The ID of the user whose image is being saved.
file (UploadFile): The uploaded image file.
db (Session): The database session.
Returns:
Any: The result of updating the user's photo path in the database.
Raises:
HTTPException: If an error occurs during the image saving process, raises a 500 Internal Server Error.
"""
try:
upload_dir = core_config.USER_IMAGES_DIR
os.makedirs(upload_dir, exist_ok=True)
@@ -73,17 +123,3 @@ async def save_user_image(user_id: int, file: UploadFile, db: Session):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def generate_email_verification_token():
"""Generate a secure token for email verification"""
return secrets.token_urlsafe(32)
def check_user_can_signup(server_settings) -> None:
"""Check if user signup is enabled on the server"""
if not server_settings.signup_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User sign-up is not enabled on this server",
)

View File

@@ -1,6 +1,7 @@
<template>
<div class="col">
<form class="bg-body-tertiary rounded p-3 shadow-sm">
<!-- Defaults -->
<h4>{{ $t('settingsServerSettingsZoneComponent.defaultsTitle') }}</h4>
<!-- Units -->
<label>{{ $t('settingsServerSettingsZoneComponent.unitsLabel') }}</label>
@@ -17,12 +18,7 @@
</select>
<!-- Num records per list -->
<label class="mt-1">{{ $t('settingsServerSettingsZoneComponent.numRecordsLabel') }}</label>
<select
class="form-select"
name="serverSettingsNumRecordsPerPage"
v-model="numRecordsPerPage"
required
>
<select class="form-select" name="serverSettingsNumRecordsPerPage" v-model="numRecordsPerPage" required>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
@@ -30,24 +26,58 @@
<option value="100">100</option>
</select>
<hr />
<!-- Sign-up -->
<h4>{{ $t('settingsServerSettingsZoneComponent.signupTitle') }}</h4>
<label class="form-label" for="serverSettingsSignUpEnabledSelect">{{
$t('settingsServerSettingsZoneComponent.enabledLabel')
}}</label>
<select class="form-select" name="serverSettingsSignUpEnabledSelect" v-model="signUp" required>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<!-- requires admin approval -->
<label class="form-label" for="serverSettingsAdminApprovalSelect">{{
$t('settingsServerSettingsZoneComponent.adminApprovalLabel')
}}</label>
<select class="form-select" name="serverSettingsAdminApprovalSelect" v-model="adminApproval" required>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<!-- requires email confirmation -->
<label class="form-label" for="serverSettingsEmailConfirmationSelect">{{
$t('settingsServerSettingsZoneComponent.emailConfirmationLabel')
}}</label>
<select class="form-select" name="serverSettingsEmailConfirmationSelect" v-model="emailConfirmation" required>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<hr />
<!-- Public shareable links -->
<h4 class="mt-4">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksLabel') }}
</h4>
<label class="form-label" for="serverSettingsPublicShareableLinksEnabledSelect">{{
$t('settingsServerSettingsZoneComponent.publicShareableLinksEnabledLabel')
}}</label>
<select
class="form-select"
name="serverSettingsPublicShareableLinksEnabledSelect"
v-model="publicShareableLinks"
required
>
$t('settingsServerSettingsZoneComponent.enabledLabel')
}}</label>
<select class="form-select" name="serverSettingsPublicShareableLinksEnabledSelect" v-model="publicShareableLinks"
required>
<option value="false">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksFalse') }}
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksTrue') }}
{{ $t('generalItems.true') }}
</option>
</select>
<div class="alert alert-warning mt-2" role="alert">
@@ -61,18 +91,14 @@
<!-- Public shareable user info -->
<label class="form-label" for="serverSettingsPublicShareableLinksShowUserInfo">{{
$t('settingsServerSettingsZoneComponent.publicShareableLinksShowUserInfoLabel')
}}</label>
<select
class="form-select"
name="serverSettingsPublicShareableLinksShowUserInfo"
v-model="publicShareableLinksUserInfo"
required
>
}}</label>
<select class="form-select" name="serverSettingsPublicShareableLinksShowUserInfo"
v-model="publicShareableLinksUserInfo" required>
<option value="false">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksFalse') }}
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksTrue') }}
{{ $t('generalItems.true') }}
</option>
</select>
<div class="alert alert-warning mt-2" role="alert">
@@ -90,50 +116,31 @@
<div class="col">
<label class="form-label" for="serverSettingsLoginPhotoLabel">{{
$t('settingsServerSettingsZoneComponent.loginPhotoLabel')
}}</label>
}}</label>
<!-- add login photo button -->
<a
class="w-100 btn btn-primary shadow-sm"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addLoginPhotoModal"
v-if="!loginPhotoSet"
>
<a class="w-100 btn btn-primary shadow-sm" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addLoginPhotoModal" v-if="!loginPhotoSet">
{{ $t('settingsServerSettingsZoneComponent.buttonAddPhoto') }}
</a>
<!-- Delete login photo section -->
<a
class="w-100 btn btn-danger"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#deleteLoginPhotoModal"
v-else
>{{ $t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto') }}</a
>
<a class="w-100 btn btn-danger" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#deleteLoginPhotoModal" v-else>{{
$t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto') }}</a>
<!-- Modal add login photo -->
<ModalComponentUploadFile
modalId="addLoginPhotoModal"
<ModalComponentUploadFile modalId="addLoginPhotoModal"
:title="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
:fileFieldLabel="$t('settingsServerSettingsZoneComponent.logonPhotoAddLabel')"
filesAccepted=".png"
actionButtonType="success"
:actionButtonText="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
@fileToEmitAction="submitUploadFileForm"
/>
:fileFieldLabel="$t('settingsServerSettingsZoneComponent.logonPhotoAddLabel')" filesAccepted=".png"
actionButtonType="success" :actionButtonText="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
@fileToEmitAction="submitUploadFileForm" />
<!-- Modal delete login photo -->
<ModalComponent
modalId="deleteLoginPhotoModal"
<ModalComponent modalId="deleteLoginPhotoModal"
:title="t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto')"
:body="`${t('settingsServerSettingsZoneComponent.modalDeleteLoginPhotoBody')}`"
actionButtonType="danger"
:body="`${t('settingsServerSettingsZoneComponent.modalDeleteLoginPhotoBody')}`" actionButtonType="danger"
:actionButtonText="t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto')"
@submitAction="submitDeleteLoginPhoto"
/>
@submitAction="submitDeleteLoginPhoto" />
</div>
</div>
</form>
@@ -159,6 +166,9 @@ const publicShareableLinksUserInfo = ref(
serverSettingsStore.serverSettings.public_shareable_links_user_info
)
const loginPhotoSet = ref(serverSettingsStore.serverSettings.login_photo_set)
const signUp = ref(serverSettingsStore.serverSettings.signup_enabled)
const adminApproval = ref(serverSettingsStore.serverSettings.signup_require_admin_approval)
const emailConfirmation = ref(serverSettingsStore.serverSettings.signup_require_email_verification)
async function updateServerSettings() {
const data = {
@@ -168,7 +178,10 @@ async function updateServerSettings() {
num_records_per_page: numRecordsPerPage.value,
public_shareable_links: publicShareableLinks.value,
public_shareable_links_user_info: publicShareableLinksUserInfo.value,
login_photo_set: loginPhotoSet.value
login_photo_set: loginPhotoSet.value,
signup_enabled: signUp.value,
signup_require_admin_approval: adminApproval.value,
signup_require_email_verification: emailConfirmation.value
}
try {
// Update the server settings in the DB
@@ -229,7 +242,7 @@ const submitDeleteLoginPhoto = async () => {
}
watch(
[units, currency, numRecordsPerPage, publicShareableLinks, publicShareableLinksUserInfo],
[units, currency, numRecordsPerPage, publicShareableLinks, publicShareableLinksUserInfo, signUp, adminApproval, emailConfirmation],
async () => {
await updateServerSettings()
},

View File

@@ -5,10 +5,11 @@
"unitsImperial": "Imperial",
"currencyLabel": "Default currency",
"numRecordsLabel": "Number of records per page",
"signupTitle": "Sign-up",
"adminApprovalLabel": "Admin approval",
"emailConfirmationLabel": "Email confirmation",
"publicShareableLinksLabel": "Public shareable links",
"publicShareableLinksEnabledLabel": "Enabled",
"publicShareableLinksTrue": "True",
"publicShareableLinksFalse": "False",
"enabledLabel": "Enabled",
"serverSettingsPublicShareableLinksEnabledWarningAlert": "Enabling this will make all publicly posted activities viewable without authentication.",
"publicShareableLinksShowUserInfoLabel": "Show user info",
"serverSettingsPublicShareableLinksShowUserWarningAlert": "Enabling this will display user information on all public links",

View File

@@ -99,10 +99,6 @@
{{ $t('loginView.signUpLink') }}
</router-link>
</div>
<!--<div>
<hr>
<button class="w-100 btn btn-lg btn-warning disabled" type="submit">{{ $t("loginView.signUpButton") }}</button>
</div>-->
</form>
</div>
</div>