Add admin notification for new sign-up approval

Introduces backend and frontend support for notifying admin users when a new user signs up and requires approval. Adds notification type, backend logic to send notifications and emails to admins, a new Vue component for displaying the notification, and i18n translations for the notification message.
This commit is contained in:
João Vitória Silva
2025-09-11 13:04:51 +01:00
parent 1ba935e995
commit 12960bb989
17 changed files with 297 additions and 15 deletions

View File

@@ -3,3 +3,4 @@ TYPE_NEW_ACTIVITY = 1
TYPE_DUPLICATE_ACTIVITY = 2
TYPE_NEW_FOLLOWER_REQUEST = 11
TYPE_NEW_FOLLOWER_REQUEST_ACCEPTED = 12
TYPE_ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST = 101

View File

@@ -8,7 +8,9 @@ import notifications.constants as notifications_constants
import notifications.crud as notifications_crud
import notifications.schema as notifications_schema
import users.user.crud as user_crud
import users.user.crud as users_crud
import users.user.models as users_models
import users.user.utils as users_utils
import websocket.utils as websocket_utils
import websocket.schema as websocket_schema
@@ -117,7 +119,7 @@ async def create_new_follower_request_notification(
db: Session,
):
try:
user = user_crud.get_user_by_id(user_id, db)
user = users_crud.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -172,7 +174,7 @@ async def create_accepted_follower_request_notification(
db: Session,
):
try:
user = user_crud.get_user_by_id(user_id, db)
user = users_crud.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -198,9 +200,7 @@ async def create_accepted_follower_request_notification(
"message": "NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION",
"notification_id": notification.id,
}
await websocket_utils.notify_frontend(
user_id, websocket_manager, json_data
)
await websocket_utils.notify_frontend(user_id, websocket_manager, json_data)
# Return the serialized notification
return notification
@@ -218,3 +218,49 @@ async def create_accepted_follower_request_notification(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
async def create_admin_new_sign_up_approval_request_notification(
user: users_models.User,
websocket_manager: websocket_schema.WebSocketManager,
db: Session,
):
try:
admins = users_utils.get_admin_users(db)
# Send notification to all admin users
for admin in admins:
# Create a notification for the new sign up request
notification = notifications_crud.create_notification(
notifications_schema.Notification(
user_id=admin.id,
type=notifications_constants.TYPE_ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST,
options={
"user_id": user.id,
"user_name": user.name,
"user_username": user.username,
},
),
db,
)
# Notify the frontend about the new sign up request
json_data = {
"message": "ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST_NOTIFICATION",
"notification_id": notification.id,
}
await websocket_utils.notify_frontend(admin.id, websocket_manager, json_data)
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in create_admin_new_sign_up_approval_request_notification: {err}",
"error",
exc=err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err

View File

@@ -25,6 +25,8 @@ import users.user_integrations.crud as user_integrations_crud
import users.user_default_gear.crud as user_default_gear_crud
import users.user_privacy_settings.crud as users_privacy_settings_crud
import notifications.utils as notifications_utils
import health_targets.crud as health_targets_crud
import profile.utils as profile_utils
@@ -36,6 +38,8 @@ import server_settings.crud as server_settings_crud
import core.database as core_database
import core.apprise as core_apprise
import websocket.schema as websocket_schema
# Define the API router
router = APIRouter()
@@ -125,6 +129,10 @@ async def signup(
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
websocket_manager: Annotated[
websocket_schema.WebSocketManager,
Depends(websocket_schema.get_websocket_manager),
],
db: Annotated[
Session,
Depends(core_database.get_db),
@@ -187,6 +195,9 @@ async def signup(
response_data["message"] + " Account is pending admin approval."
)
response_data["admin_approval_required"] = True
await sign_up_tokens_utils.send_sign_up_admin_approval_email(created_user, email_service, db)
await notifications_utils.create_admin_new_sign_up_approval_request_notification(created_user, websocket_manager, db)
if (
not server_settings.signup_require_email_verification
and not server_settings.signup_require_admin_approval

View File

@@ -1,5 +1,6 @@
import core.apprise as core_apprise
def get_signup_confirmation_email_en(
user_name: str, signup_link: str, email_service: core_apprise.AppriseService
) -> tuple:
@@ -97,4 +98,75 @@ def get_signup_confirmation_email_en(
The Endurain team
""".strip()
return subject, html_content, text_content
return subject, html_content, text_content
def get_admin_signup_notification_email_en(
user_name: str, sign_up_user_name: str, email_service: core_apprise.AppriseService
) -> tuple:
subject = "Endurain - New user sign-up pending approval"
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;">New sign-up requires approval</h3>
</div>
<div style="margin-bottom: 30px;">
<p>Hello {user_name},</p>
<p>A new user has signed up and is awaiting approval:</p>
<div style="background-color: #e9ecef; border: 1px solid #ccc; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>User:</strong> {sign_up_user_name}
</div>
<p>Please log in to the Endurain admin panel to review and approve this request.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{email_service.frontend_host}/settings" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Go to Admin Panel</a>
</div>
</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 system</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()
text_content = f"""
Hello {user_name},
A new user has signed up and is awaiting approval.
User: {sign_up_user_name}
Please log in to the Endurain admin panel to review and approve this request:
{email_service.frontend_host}/settings
Best regards,
The Endurain system
""".strip()
return subject, html_content, text_content

View File

@@ -13,7 +13,7 @@ import sign_up_tokens.schema as sign_up_tokens_schema
import sign_up_tokens.crud as sign_up_tokens_crud
import users.user.models as users_models
import users.user.crud as users_crud
import users.user.utils as users_utils
import core.apprise as core_apprise
import core.logger as core_logger
@@ -31,7 +31,8 @@ def create_sign_up_token(user_id: int, db: Session) -> str:
user_id=user_id,
token_hash=token_hash,
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(hours=1), # 1 hour expiration
expires_at=datetime.now(timezone.utc)
+ timedelta(hours=24), # 24 hour expiration
used=0,
)
@@ -74,6 +75,36 @@ async def send_sign_up_email(
)
async def send_sign_up_admin_approval_email(
user: users_models.User, email_service: core_apprise.AppriseService, db: Session
) -> bool:
# Check if email service is configured
if not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email service is not configured",
)
admins = users_utils.get_admin_users(db)
# Send email to all admin users
for admin in admins:
# use default email message in English
subject, html_content, text_content = (
sign_up_tokens_email_messages.get_admin_signup_notification_email_en(
admin.name, user.name, email_service
)
)
# Send email
await email_service.send_email(
to_emails=[admin.email],
subject=subject,
html_content=html_content,
text_content=text_content,
)
def use_sign_up_token(token: str, db: Session):
# Hash the provided token to find the database record
token_hash = hashlib.sha256(token.encode()).hexdigest()

View File

@@ -85,7 +85,6 @@ def get_users_with_pagination(db: Session, page_number: int = 1, num_records: in
# Return the users
return users
except Exception as err:
# Log the exception
core_logger.print_to_log(
@@ -254,6 +253,32 @@ def get_user_by_id_if_is_public(user_id: int, db: Session):
) from err
def get_users_admin(db: Session):
try:
# Get the users from the database and format the birthdate
users = [
users_utils.format_user_birthdate(user)
for user in db.query(users_models.User)
.filter(users_models.User.access_type == 2)
.all()
]
# If the users were not found, return None
if not users:
return None
# Return the users
return users
except Exception as err:
# Log the exception
core_logger.print_to_log(f"Error in get_users_admin: {err}", "error", exc=err)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def create_user(user: users_schema.UserCreate, db: Session):
try:
# Create a new user
@@ -599,7 +624,7 @@ def create_signup_user(
users_models.User: The newly created user object.
Raises:
HTTPException:
HTTPException:
- 409 Conflict if the email or username is not unique.
- 500 Internal Server Error for any other exceptions.
"""

View File

@@ -1,14 +1,11 @@
import os
import glob
import secrets
from fastapi import HTTPException, status, UploadFile
from sqlalchemy.orm import Session
import shutil
import session.constants as session_constants
import users.user.crud as users_crud
import users.user.schema as users_schema
@@ -33,6 +30,18 @@ def check_user_is_active(user: users_schema.User) -> None:
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
def get_admin_users(db: Session):
admins = users_crud.get_users_admin(db)
if not admins:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No admin users found",
)
return admins
def delete_user_photo_filesystem(user_id: int):

View File

@@ -56,6 +56,12 @@
v-else-if="notification.type === 12"
@notificationRead="markNotificationAsRead"
/>
<AdminNewSignUpApprovalRequestNotificationComponent
:notification="notification"
:showDropdown="showDropdown"
v-else-if="notification.type === 101"
@notificationRead="markNotificationAsRead"
/>
</li>
<li v-if="totalPages > 1 && totalPages > pageNumber">
<a class="dropdown-item" @click="setPageNumber">Load more...</a>
@@ -75,6 +81,7 @@ import { notifications } from '@/services/notificationsService'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
import { useAuthStore } from '@/stores/authStore'
import AdminNewSignUpApprovalRequestNotificationComponent from '@/components/Notifications/AdminNewSignUpApprovalRequestNotificationComponent.vue'
import NewAcceptedRequestNotificationComponent from '@/components/Notifications/NewAcceptedRequestNotificationComponent.vue'
import NewActivityNotificationComponent from '@/components/Notifications/NewActivityNotificationComponent.vue'
import NewActivityDuplicateStartTimeNotificationComponent from '@/components/Notifications/NewActivityDuplicateStartTimeNotificationComponent.vue'
@@ -177,7 +184,8 @@ onMounted(async () => {
(data.message === 'NEW_ACTIVITY_NOTIFICATION' ||
data.message === 'NEW_DUPLICATE_ACTIVITY_START_TIME_NOTIFICATION' ||
data.message === 'NEW_FOLLOWER_REQUEST_NOTIFICATION' ||
data.message === 'NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION')
data.message === 'NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION' ||
data.message === 'ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST_NOTIFICATION')
) {
await fetchNotificationById(data.notification_id)
}

View File

@@ -0,0 +1,50 @@
<template>
<router-link
class="dropdown-item link-body-emphasis text-wrap"
:to="{ name: 'settings' }"
>
<span
><b>{{ $t('adminNewSignUpApprovalRequestNotificationComponent.title') }}</b></span
>
<br />
<span class="fw-lighter">
{{ notification.options['user_name'] }} - @{{ notification.options['user_username']
}}{{ $t('adminNewSignUpApprovalRequestNotificationComponent.subTitle') }}
</span>
</router-link>
</template>
<script setup>
import { computed, watch } from 'vue'
// Importing the i18n
import { useI18n } from 'vue-i18n'
import { notifications } from '@/services/notificationsService'
const { t } = useI18n()
const emit = defineEmits(['notificationRead'])
const props = defineProps({
notification: {
type: Object,
required: true
},
showDropdown: {
type: Boolean,
required: true
}
})
const dropdownState = computed(() => {
return props.showDropdown
})
function markNotificationAsRead() {
if (props.notification.read === false && props.showDropdown === true) {
notifications.markNotificationAsRead(props.notification.id)
emit('notificationRead', props.notification.id)
}
}
// Watch the page number variable.
watch(dropdownState, markNotificationAsRead, { immediate: false })
</script>

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}

View File

@@ -40,6 +40,7 @@ const componentPaths = {
navbarBottomMobileComponent: 'components/navbar/navbarBottomMobileComponent.json',
navbarComponent: 'components/navbar/navbarComponent.json',
// Notifications components
adminNewSignUpApprovalRequestNotificationComponent: 'components/notifications/adminNewSignUpApprovalRequestNotificationComponent.json',
navbarNotificationsComponent: 'components/notifications/navbarNotificationsComponent.json',
newAcceptedRequestNotificationComponent:
'components/notifications/newAcceptedRequestNotificationComponent.json',

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}

View File

@@ -0,0 +1,4 @@
{
"title": "New sign-up request",
"subTitle": " has requested to sign-up"
}