Merge branch 'copilot/fix-274' into pre-release

This commit is contained in:
João Vitória Silva
2025-09-04 14:19:27 +01:00
42 changed files with 1919 additions and 45 deletions

View File

@@ -11,4 +11,12 @@ ENDURAIN_HOST=https://endurain.example.com
BEHIND_PROXY=true
POSTGRES_DB=endurain
POSTGRES_USER=endurain
PGDATA=/var/lib/postgresql/data
PGDATA=/var/lib/postgresql/data/pgdata
# Email configuration (for password reset functionality)
#SMTP_HOST=smtp.protonmail.ch
#SMTP_PORT=587
#SMTP_USERNAME=your-email@example.com
#SMTP_PASSWORD=your-app-password
#SMTP_SECURE=true
#SMTP_SECURE_TYPE=starttls

View File

@@ -20,6 +20,7 @@ import health_data.models
import health_targets.models
import migrations.models
import notifications.models
import password_reset_tokens.models
import server_settings.models
import session.models
import users.user.models

View File

@@ -0,0 +1,80 @@
"""Add password reset tokens table
Revision ID: a1b2c3d4e5f6
Revises: 2fb0ae78dea9
Create Date: 2025-08-29 20:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "2fb0ae78dea9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"password_reset_tokens",
sa.Column(
"id",
sa.String(length=64),
nullable=False,
comment="Password reset token ID",
),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
comment="User ID that the password reset token belongs to",
),
sa.Column(
"token_hash",
sa.String(length=128),
nullable=False,
comment="Hashed password reset token",
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
comment="Token creation date (datetime)",
),
sa.Column(
"expires_at",
sa.DateTime(),
nullable=False,
comment="Token expiration date (datetime)",
),
sa.Column(
"used",
sa.Boolean(),
nullable=False,
comment="Token usage status (False - unused, True - used)",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_password_reset_tokens_user_id"),
"password_reset_tokens",
["user_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f("ix_password_reset_tokens_user_id"), table_name="password_reset_tokens"
)
op.drop_table("password_reset_tokens")
# ### end Alembic commands ###

218
backend/app/core/apprise.py Normal file
View File

@@ -0,0 +1,218 @@
import os
import apprise
from typing import List
import core.logger as core_logger
class AppriseService:
def __init__(self):
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.smtp_username = os.getenv("SMTP_USERNAME")
self.smtp_password = os.getenv("SMTP_PASSWORD")
self.smtp_secure = os.getenv("SMTP_SECURE", "true").lower()
self.smtp_secure_type = os.getenv("SMTP_SECURE_TYPE", "starttls").lower()
self.frontend_host = os.getenv("ENDURAIN_HOST")
def _build_smtp_url(self) -> str:
"""
Build and return an SMTP-style URL used by Apprise-like notification backends.
The method constructs a mailto/mailtos URL with a placeholder recipient ('_')
and appends connection and credential parameters as query string values.
Behavior:
- If self.smtp_secure == "true", the URL scheme is "mailtos" and a "mode"
parameter with the value of self.smtp_secure_type is added.
- Otherwise the URL scheme is "mailto".
- The following query parameters are appended in order: mode (optional),
user, pass, smtp, port.
Uses the instance attributes:
- smtp_secure (str): "true" to enable secure mode, otherwise omitted.
- smtp_secure_type (str): type of secure transport (e.g., "tls", "ssl").
- smtp_username (str): username to include as the "user" query parameter.
- smtp_password (str): password to include as the "pass" query parameter.
- smtp_host (str): SMTP host to include as the "smtp" query parameter.
- smtp_port (str|int): SMTP port to include as the "port" query parameter.
Returns:
str: The assembled URL, e.g.
"mailto://_?user=USERNAME&pass=PASSWORD&smtp=smtp.example.com&port=587"
or when secure:
"mailtos://_?mode=tls&user=...&pass=...&smtp=...&port=..."
Notes and cautions:
- Credentials are embedded in the query string; treat the returned URL as
sensitive and avoid logging or exposing it.
- Values are concatenated directly and are not URL-encoded by this method.
If attribute values may contain special characters, URL-encode them before
building the URL to ensure a valid query string.
- The method does not validate the host, port, or credential contents.
"""
# Build base URL
url = f"mailto://_"
params = []
# if secure change url to mailtos and append secure type
if self.smtp_secure == "true":
url = f"mailtos://_"
params.append(f"mode={self.smtp_secure_type}")
# Add query parameters
params.append(f"user={self.smtp_username}")
params.append(f"pass={self.smtp_password}")
params.append(f"smtp={self.smtp_host}")
params.append(f"port={self.smtp_port}")
# Append any additional parameters to the url
if params:
url += "?" + "&".join(params)
# return the final URL
return url
async def send_email(
self,
to_emails: List[str],
subject: str,
html_content: str | None = None,
text_content: str | None = None,
) -> bool:
"""
Send an email to one or more recipients using Apprise over SMTP.
This asynchronous method prefers HTML content when provided and falls back to
plain text. It builds a temporary Apprise instance, constructs an SMTP URL via
self._build_smtp_url(), appends each recipient using the "&to=<email>" query
parameter, and invokes Apprise's async_notify to send the message. The method
performs configuration checks and logs outcomes; exceptions are caught and
logged and result in a False return.
Parameters:
to_emails (List[str]): Recipient email addresses. If empty, no send is
attempted and the method returns False.
subject (str): The subject line for the email.
html_content (str | None): HTML body of the email. Preferred when provided.
text_content (str | None): Plain-text body of the email. Used if
html_content is None.
Returns:
bool: True if the notification was reported as successfully sent by Apprise;
False otherwise (including when SMTP is not configured, there are no
recipients, required content is missing, or an exception occurs).
Raises:
None propagated: All exceptions are caught internally, logged via core_logger,
and result in a False return value.
Notes:
- The method sets body_format to "html" when html_content is used, otherwise
to "text".
- The method relies on self.is_smtp_configured() to determine whether to
attempt sending and on self._build_smtp_url() to construct the base SMTP URL.
- Callers must await this coroutine.
"""
# Use HTML content primarily, fall back to text if HTML not available
content = html_content if html_content else text_content
body_format = "html" if html_content else "text"
try:
# set success as false
success = False
# Check if emails are defined and SMTP is configured
if to_emails and self.is_smtp_configured():
# Create a temporary Apprise instance
temp_apprise = apprise.Apprise()
smtp_url = self._build_smtp_url()
# Add recipients to the SMTP URL
for email in to_emails:
email_url = f"{smtp_url}&to={email}"
temp_apprise.add(email_url)
success = await temp_apprise.async_notify(
title=subject, body=content, body_format=body_format
)
if success:
core_logger.print_to_log(f"Emails sent successfully: {subject}", "info")
else:
core_logger.print_to_log_and_console(
f"Failed to send emails: {subject}", "warning"
)
return success
except Exception as err:
core_logger.print_to_log(
f"Failed to send emails '{subject}': {err}", "error", exc=err
)
return False
def is_configured(self) -> bool:
"""
Return whether the notification system is configured.
This convenience wrapper delegates to is_smtp_configured() and returns True
when SMTP-based notifications are set up, False otherwise. It provides an
abstraction so callers do not need to know the underlying mechanism used
to determine configuration status.
Returns:
bool: True if the notification system (currently SMTP) is configured,
False if it is not.
Notes:
- Currently only checks SMTP configuration via is_smtp_configured().
Extend this method if additional notification backends need to be
considered in the future.
"""
return self.is_smtp_configured()
def is_smtp_configured(self) -> bool:
"""
Return whether SMTP is configured.
Checks the instance attribute `smtp_host` and returns True if it is set to a truthy value
(e.g., a non-empty string). Returns False if `smtp_host` is `None`, an empty string, or
otherwise falsy.
Returns:
bool: True when SMTP host is present (SMTP considered configured), False otherwise.
Notes:
This method only inspects the truthiness of `smtp_host`; it does not validate the host
format or attempt any network connection.
"""
return bool(self.smtp_host)
def get_email_service():
"""
Return the application's email service instance.
This function provides access to the module-level email service object used
for sending email notifications. It is a convenience accessor that returns
whichever email service implementation was initialized during application
startup (for example, a configured SMTP client, a wrapper around a third-party
notification library, or a mock for testing).
Returns:
object: The initialized email service instance. The concrete type and API
depend on how the application configured the service.
Raises:
RuntimeError: If the email service has not been initialized before calling
this function.
Usage:
Ensure the email service is initialized during application startup. Consumers
may call this function to obtain the service and perform send operations:
svc = get_email_service()
svc.send(to="user@example.com", subject="Hi", body="Hello")
"""
return email_service
email_service = AppriseService()

View File

@@ -27,7 +27,9 @@ TZ = os.getenv("TZ", "UTC")
REVERSE_GEO_PROVIDER = os.getenv("REVERSE_GEO_PROVIDER", "nominatim").lower()
PHOTON_API_HOST = os.getenv("PHOTON_API_HOST", "photon.komoot.io").lower()
PHOTON_API_USE_HTTPS = os.getenv("PHOTON_API_USE_HTTPS", "true").lower() == "true"
NOMINATIM_API_HOST = os.getenv("NOMINATIM_API_HOST", "nominatim.openstreetmap.org").lower()
NOMINATIM_API_HOST = os.getenv(
"NOMINATIM_API_HOST", "nominatim.openstreetmap.org"
).lower()
NOMINATIM_API_USE_HTTPS = os.getenv("NOMINATIM_API_USE_HTTPS", "true").lower() == "true"
GEOCODES_MAPS_API = os.getenv("GEOCODES_MAPS_API", "changeme")
try:
@@ -59,6 +61,15 @@ def check_required_env_vars():
"ENDURAIN_HOST",
]
# Email is optional but warn if not configured
email_vars = ["SMTP_HOST", "SMTP_USERNAME", "SMTP_PASSWORD"]
for var in email_vars:
if var not in os.environ:
core_logger.print_to_log_and_console(
f"Email not configured (missing: {', '.join(var)}). Password reset feature will not work.",
"info",
)
for var in required_env_vars:
if var not in os.environ:
core_logger.print_to_log_and_console(

View File

@@ -24,6 +24,7 @@ import gears.gear_components.router as gear_components_router
import health_data.router as health_data_router
import health_targets.router as health_targets_router
import notifications.router as notifications_router
import password_reset_tokens.router as password_reset_tokens_router
import profile.router as profile_router
import server_settings.public_router as server_settings_public_router
import server_settings.router as server_settings_router
@@ -136,6 +137,11 @@ router.include_router(
Security(session_security.check_scopes, scopes=["profile"]),
],
)
router.include_router(
password_reset_tokens_router.router,
prefix=core_config.ROOT_PATH,
tags=["password_reset_tokens"],
)
router.include_router(
profile_router.router,
prefix=core_config.ROOT_PATH + "/profile",

View File

@@ -7,6 +7,8 @@ import strava.utils as strava_utils
import garmin.activity_utils as garmin_activity_utils
import garmin.health_utils as garmin_health_utils
import password_reset_tokens.utils as password_reset_tokens_utils
import core.logger as core_logger
# scheduler = BackgroundScheduler()
@@ -50,6 +52,14 @@ def start_scheduler():
"retrieve last day Garmin Connect users body composition",
)
add_scheduler_job(
password_reset_tokens_utils.delete_invalid_tokens_from_db,
"interval",
60,
[],
"delete invalid password reset tokens from the database",
)
def add_scheduler_job(func, interval, minutes, args, description):
try:

View File

@@ -21,6 +21,8 @@ import session.schema as session_schema
import strava.activity_utils as strava_activity_utils
import strava.utils as strava_utils
import password_reset_tokens.utils as password_reset_tokens_utils
from core.routes import router as api_router
@@ -60,6 +62,12 @@ async def startup_event():
)
garmin_health_utils.retrieve_garminconnect_users_bc_for_days(1)
# Delete invalid password reset tokens
core_logger.print_to_log_and_console(
"Deleting invalid password reset tokens from the database"
)
password_reset_tokens_utils.delete_invalid_tokens_from_db()
def shutdown_event():
# Log the shutdown event

View File

@@ -0,0 +1,233 @@
from fastapi import HTTPException, status
from sqlalchemy import and_
from sqlalchemy.orm import Session
from datetime import datetime, timezone
import password_reset_tokens.schema as password_reset_tokens_schema
import password_reset_tokens.models as password_reset_tokens_models
import core.logger as core_logger
def create_password_reset_token(
token: password_reset_tokens_schema.PasswordResetToken, db: Session
) -> password_reset_tokens_models.PasswordResetToken:
"""
Create and persist a new password reset token record in the database.
This function constructs a PasswordResetToken ORM model from the provided
schema object, adds it to the given SQLAlchemy session, commits the
transaction, refreshes the instance from the database, and returns the
persisted model instance.
Parameters:
token (password_reset_tokens_schema.PasswordResetToken): A schema object
containing the token data to be stored. Expected attributes include
id, user_id, token_hash, created_at, expires_at, and used.
db (Session): An active SQLAlchemy Session used to persist the model.
Returns:
password_reset_tokens_models.PasswordResetToken: The persisted ORM model
instance representing the created password reset token, refreshed from
the database to include any defaults or database-side generated values.
Side effects:
- Adds a new PasswordResetToken instance to the provided DB session.
- Commits the session, causing the INSERT to be executed.
- Refreshes the instance from the database.
Errors:
On any exception during add/commit/refresh the session is rolled back,
the error is logged, and an HTTPException with status_code
500 (Internal Server Error) is raised (the original exception is chained).
"""
try:
# Create a new password reset token
db_token = password_reset_tokens_models.PasswordResetToken(
id=token.id,
user_id=token.user_id,
token_hash=token.token_hash,
created_at=token.created_at,
expires_at=token.expires_at,
used=token.used,
)
# Add the token to the database
db.add(db_token)
db.commit()
db.refresh(db_token)
# Return the token
return db_token
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in create_password_reset_token: {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 get_password_reset_token_by_hash(
token_hash: str, db: Session
) -> password_reset_tokens_models.PasswordResetToken | None:
try:
# Get the token from the database
db_token = (
db.query(password_reset_tokens_models.PasswordResetToken)
.filter(
and_(
password_reset_tokens_models.PasswordResetToken.token_hash
== token_hash,
password_reset_tokens_models.PasswordResetToken.used == False,
password_reset_tokens_models.PasswordResetToken.expires_at
> datetime.now(timezone.utc),
)
)
.first()
)
# Return the token (can be None if not found)
return db_token
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in get_password_reset_token_by_hash: {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 mark_password_reset_token_used(
token_id: str, db: Session
) -> password_reset_tokens_models.PasswordResetToken | None:
"""
Mark a password reset token as used.
This function looks up a PasswordResetToken by its identifier, sets its `used`
attribute to True, and commits the change to the database.
Parameters
----------
token_id : str
The unique identifier of the password reset token to mark as used.
db : Session
An active SQLAlchemy Session used to query and persist changes.
Returns
-------
password_reset_tokens_models.PasswordResetToken | None
The updated PasswordResetToken instance if found and updated; otherwise
None if no token with the given id exists.
Side effects
------------
- If the token is found, its `used` field is set to True and the change is
committed to the database.
- On unexpected errors, the transaction is rolled back, the error is logged
(via core_logger), and an HTTPException with status 500 is raised.
Exceptions
----------
HTTPException
Raised with status code 500 (Internal Server Error) when an unexpected
error occurs during the database operation.
"""
try:
# Get the token from the database
db_token = (
db.query(password_reset_tokens_models.PasswordResetToken)
.filter(password_reset_tokens_models.PasswordResetToken.id == token_id)
.first()
)
if db_token:
# Mark the token as used
db_token.used = True
db.commit()
return db_token
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in mark_password_reset_token_used: {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 delete_expired_password_reset_tokens(db: Session):
"""
Delete expired password reset tokens from the database.
This function removes all PasswordResetToken records whose `expires_at`
timestamp is strictly earlier than the current UTC time (datetime.now(timezone.utc)).
On success the transaction is committed and the number of deleted rows is returned.
If any exception occurs during the operation, the session is rolled back,
the error is logged via core_logger.print_to_log, and an HTTPException with
status 500 (Internal Server Error) is raised.
Parameters
----------
db : Session
An active SQLAlchemy session used to query, delete, and commit changes.
Returns
-------
int
The number of password reset token rows deleted.
Raises
------
fastapi.HTTPException
Raised with status_code=500 if an unexpected error occurs while deleting
tokens or committing the transaction. The original exception is logged
and the transaction is rolled back before raising.
"""
try:
# Delete expired tokens
num_deleted = (
db.query(password_reset_tokens_models.PasswordResetToken)
.filter(
password_reset_tokens_models.PasswordResetToken.expires_at
< datetime.now(timezone.utc)
)
.delete()
)
# Commit the transaction
db.commit()
return num_deleted
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in delete_expired_password_reset_tokens: {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

@@ -0,0 +1,103 @@
import core.apprise as core_apprise
def get_password_reset_email_en(
user_name: str, reset_link: str, email_service: core_apprise.AppriseService
) -> tuple:
"""
Return the subject, HTML body, and plain-text body for an English password-reset email.
Args:
user_name (str): The recipient's display name inserted into the greeting.
reset_link (str): The URL the user will follow to reset their password; 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
"Reset Password" button linking to `reset_link`, a security notice about a 1-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, reset instructions, the raw `reset_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 `reset_link` and `user_name` are properly validated/sanitized as needed.
- The HTML is crafted with inline styles for broad email-client compatibility.
"""
subject = "Endurain - Password reset"
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;">Password reset request</h3>
</div>
<div style="margin-bottom: 30px;">
<p>Hi {user_name},</p>
<p>You requested to reset your password for your Endurain account. Click the button below to reset your
password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{reset_link}" style="background-color: #0d6efd; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Reset Password</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 link will expire in 1 hour for security reasons.
</div>
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
</p>
<p>If the button above doesn't work, you can copy and paste the following link into your browser:</p>
<p style="word-break: break-all; color: #0d6efd;">{reset_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: #0d6efd;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
Source code at: <a style="color: #0d6efd;"
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
</div>
</div>
</body>
</html>
""".strip()
# Create text version
text_content = f"""
Hi {user_name},
You requested to reset your password for your Endurain account.
Please click the following link to reset your password:
{reset_link}
This link will expire in 1 hour for security reasons.
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
Best regards,
The Endurain team
""".strip()
return subject, html_content, text_content

View File

@@ -0,0 +1,41 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
ForeignKey,
Boolean,
)
from sqlalchemy.orm import relationship
from core.database import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id = Column(String(length=64), nullable=False, primary_key=True)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="User ID that the password reset token belongs to",
)
token_hash = Column(
String(length=128), nullable=False, comment="Hashed password reset token"
)
created_at = Column(
DateTime, nullable=False, comment="Token creation date (datetime)"
)
expires_at = Column(
DateTime, nullable=False, comment="Token expiration date (datetime)"
)
used = Column(
Boolean,
nullable=False,
default=False,
comment="Token usage status (False - unused, True - used)",
)
# Define a relationship to the User model
user = relationship("User", back_populates="password_reset_tokens")

View File

@@ -0,0 +1,150 @@
from typing import Annotated
from fastapi import (
APIRouter,
Depends,
HTTPException,
status,
)
from sqlalchemy.orm import Session
import password_reset_tokens.schema as password_reset_tokens_schema
import password_reset_tokens.utils as password_reset_tokens_utils
import session.security as session_security
import core.database as core_database
import core.apprise as core_apprise
# Define the API router
router = APIRouter()
@router.post("/password-reset/request")
async def request_password_reset(
request_data: password_reset_tokens_schema.PasswordResetRequest,
email_service: Annotated[
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""
Asynchronously handle a password reset request.
Attempts to send a password reset email for the provided email address using an
injected email service and a database session. The endpoint intentionally returns
a generic success message to avoid revealing whether the provided email exists
in the system.
Parameters
----------
request_data : password_reset_tokens_schema.PasswordResetRequest
Pydantic model containing the email address to send the reset link to.
email_service : core_apprise.AppriseService
Dependency-injected service responsible for sending emails.
db : Session
Dependency-injected database session.
Returns
-------
dict
A generic success message:
{"message": "If the email exists in the system, a password reset link has been sent."}
Raises
------
HTTPException
Raised with status_code=status.HTTP_500_INTERNAL_SERVER_ERROR if sending
the password reset email fails.
Other Errors
Validation errors from FastAPI/Pydantic or dependency resolution errors may
be propagated by the framework.
Notes
-----
- This function is asynchronous.
- Side effects include attempting to send an email and potentially interacting
with the database (e.g., creating or updating a password reset token).
- The generic response is used to mitigate user enumeration attacks.
"""
success = await password_reset_tokens_utils.send_password_reset_email(
request_data.email, email_service, db
)
# if the email was sent successfully send a generic success message
if success:
return {
"message": "If the email exists in the system, a password reset link has been sent."
}
# If the email sending failed, raise an error
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Unable to send password reset email",
)
@router.post("/password-reset/confirm")
async def confirm_password_reset(
confirm_data: password_reset_tokens_schema.PasswordResetConfirm,
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""
Confirm a password reset using a one-time token.
Validates the provided new password against configured complexity rules and, if
valid, delegates to the password reset token utility to apply the new password.
Parameters:
confirm_data (password_reset_tokens_schema.PasswordResetConfirm):
Object containing the reset token and the requested new password
(expected attributes: 'token', 'new_password').
db (Session):
Database session provided by dependency injection (core_database.get_db).
Returns:
dict: A JSON-serializable mapping with a success message, e.g.:
{"message": "Password reset successful"}
Raises:
HTTPException:
- Raised with status 400 if the new password does not meet complexity
requirements. The response detail contains the validation message.
- May be raised by password_reset_tokens_utils.use_password_reset_token
for problems such as an invalid, expired, or already-consumed token,
or for database-related errors.
Side effects:
- Updates the user's password in persistent storage.
- Invalidates/consumes the provided password reset token.
- Persists changes using the provided database session.
Notes:
- Password complexity rules are enforced by
session_security.is_password_complexity_valid.
- Token application, user lookup, password hashing, and database commits
are handled by password_reset_tokens_utils.use_password_reset_token.
"""
# Check if the password meets the complexity requirements
is_valid, message = session_security.is_password_complexity_valid(
confirm_data.new_password
)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
# Use the token to reset password
password_reset_tokens_utils.use_password_reset_token(
confirm_data.token, confirm_data.new_password, db
)
return {"message": "Password reset successful"}

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
class PasswordResetToken(BaseModel):
id: str
user_id: int
token_hash: str
created_at: datetime
expires_at: datetime
used: bool
model_config = {"from_attributes": True}
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str

View File

@@ -0,0 +1,321 @@
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from fastapi import (
HTTPException,
status,
)
from uuid import uuid4
from sqlalchemy.orm import Session
import password_reset_tokens.email_messages as password_reset_tokens_email_messages
import password_reset_tokens.schema as password_reset_tokens_schema
import password_reset_tokens.crud as password_reset_tokens_crud
import users.user.crud as users_crud
import core.apprise as core_apprise
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.
Parameters
----------
user_id : int
The ID of the user for whom the password reset token will be created.
db : Session
Database session used to persist the token (e.g., an SQLAlchemy Session).
Returns
-------
str
The plaintext password reset token that should be delivered to the user (for example via email).
The function stores only a hash of this token in the database.
Behavior / Side effects
-----------------------
- Generates a secure token and a corresponding hash.
- Creates a PasswordResetToken record with a unique id, user_id, token_hash, created_at,
expires_at (1 hour after creation), and used flag set to 0.
- Persists the PasswordResetToken record to the provided database session.
Security notes
--------------
- Treat the returned plaintext token as sensitive; transmit it over secure channels only.
- Only the token hash is stored in the database to avoid storing secrets in plaintext.
- When validating a token later, compare the provided token against the stored hash,
ensure it has not expired, and verify it has not already been used.
- Consider adding rate limiting, logging, and additional checks to reduce abuse.
Exceptions
----------
May raise exceptions originating from token generation/hashing utilities or from the database layer
(e.g., integrity or operational errors). Callers should handle or propagate these exceptions as appropriate.
Example
-------
token = create_password_reset_token(user_id=42, db=session)
# 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()
# Create token object
reset_token = password_reset_tokens_schema.PasswordResetToken(
id=str(uuid4()),
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
used=0,
)
# Save to database
password_reset_tokens_crud.create_password_reset_token(reset_token, db)
# Return the plain token (not the hash)
return token
async def send_password_reset_email(
email: str, email_service: core_apprise.AppriseService, db: Session
) -> bool:
"""
Asynchronously send a password reset email for the given address.
This function performs the following steps:
1. Verifies that the provided email service is configured; if not, raises an HTTP 503.
2. Attempts to locate the user record for the given email in the provided DB session.
- For security (to avoid user enumeration), if the user does not exist the function
returns True and does not indicate existence to the caller.
3. Verifies the located user is active (expects user.is_active == 1).
- If the user is inactive the function returns True for the same security reason.
4. Creates a password reset token and persists it via create_password_reset_token.
5. Constructs a frontend reset URL using the email_service.frontend_host and the token.
6. Builds a default English email subject/body via password_reset_tokens_email_messages
and delegates actual sending to email_service.send_email.
7. Returns the boolean result from the email service send operation.
Parameters
- email (str): Recipient email address for the password reset message.
- email_service (core_apprise.AppriseService): An email service instance used to
construct the frontend host and send the message. Must implement is_configured()
and an async send_email(...) method that returns a bool.
- db (Session): SQLAlchemy Session (or equivalent) used to look up the user and
persist the reset token.
Returns
- bool: True when the operation is considered successful. This includes the cases
where the user does not exist or is inactive (to avoid revealing account existence).
Otherwise returns the boolean result produced by the email_service.send_email call
(False typically indicates the email failed to send).
Raises
- HTTPException (status 503): If the email service is not configured.
- Any other exceptions raised by the DB access, token creation, or email service
may propagate to the caller.
Side effects and security notes
- A password reset token is generated and stored when a matching active user is found.
- The function deliberately avoids disclosing whether an email address maps to a user
or whether that user is active, to mitigate user enumeration attacks.
- The reset link contains the raw token; callers should ensure the frontend and token
handling enforce appropriate expiry and single-use semantics.
- As an async function, it must be awaited.
Example
- await send_password_reset_email("user@example.com", email_service, db)
"""
# 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",
)
# Find user by email
user = users_crud.get_user_by_email(email, db)
if not user:
# Don't reveal if email exists or not for security
return True
# Check if user is active
if user.is_active != 1:
# Don't reveal if user is inactive for security
return True
# Generate password reset token
token = create_password_reset_token(user.id, db)
# Generate reset link
reset_link = f"{email_service.frontend_host}/reset-password?token={token}"
# use default email message in English
subject, html_content, text_content = (
password_reset_tokens_email_messages.get_password_reset_email_en(
user.name, reset_link, email_service
)
)
# Send email
return await email_service.send_email(
to_emails=[email],
subject=subject,
html_content=html_content,
text_content=text_content,
)
def use_password_reset_token(token: str, new_password: str, db: Session):
"""
Use a password reset token to update a user's password and mark the token as used.
The function:
- Hashes the provided plain-text token (SHA-256) and looks up the corresponding
password reset record in the database.
- If no matching record is found, raises an HTTPException with status 400.
- Delegates password update to users_crud.edit_user_password.
- Marks the token as used via password_reset_tokens_crud.mark_password_reset_token_used.
- Logs unexpected errors and raises an HTTPException with status 500 on failure.
Parameters:
- token (str): The plain-text password reset token supplied by the user. This
function will hash it before database lookup.
- new_password (str): The new plain-text password to set for the user. Password
validation/hashing is expected to be handled by the underlying users_crud.
- db (Session): An active SQLAlchemy Session (or equivalent) used for DB operations.
Transaction management (commit/rollback) is expected to be handled by the caller
or the CRUD functions.
Returns:
- None
Side effects:
- Updates the user's password in the database.
- Marks the password reset token record as used/consumed.
- Writes error information to the application log on unexpected failures.
Exceptions:
- Raises HTTPException(status_code=400) when the token is invalid or expired.
- Re-raises any HTTPException raised by underlying CRUD functions.
- Raises HTTPException(status_code=500) for unexpected internal errors.
Security notes:
- The token is hashed (SHA-256) before lookup to avoid storing/using the plain token.
- Ensure new_password meets application password policy and that users_crud
securely hashes and salts passwords before persisting.
"""
# Hash the provided token to find the database record
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Look up the token in the database
db_token = password_reset_tokens_crud.get_password_reset_token_by_hash(
token_hash, db
)
if not db_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired password reset token",
)
# Update user password
try:
users_crud.edit_user_password(db_token.user_id, new_password, db)
# Mark token as used
password_reset_tokens_crud.mark_password_reset_token_used(db_token.id, db)
except HTTPException as http_err:
raise http_err
except Exception as err:
core_logger.print_to_log(
f"Error in use_password_reset_token: {err}", "error", exc=err
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def delete_invalid_tokens_from_db():
"""
Remove expired password reset tokens from the database.
Opens a new database session, calls the password_reset_tokens_crud layer to
delete any expired password reset tokens, and logs the number of deleted
tokens if one or more were removed. The database session is guaranteed to be
closed whether the operation succeeds or an exception is raised.
Behavior:
- Creates a SessionLocal() session.
- Invokes password_reset_tokens_crud.delete_expired_password_reset_tokens(db),
which should return the number of deleted tokens (int).
- If the returned count is greater than zero, logs an informational message
via core_logger.print_to_log_and_console.
- Always closes the database session in a finally block.
Returns:
- None
Exceptions:
- Exceptions raised by the CRUD layer or the logger will propagate to the
caller, but the database session will still be closed before propagation.
Notes:
- This function performs destructive, persistent changes (deletions) and is
intended to be run as part of maintenance (for example, a scheduled task).
- The operation is effectively idempotent: running it repeatedly when there
are no expired tokens will have no further effect.
"""
# Create a new database session
db = SessionLocal()
try:
# Get num tokens deleted
num_deleted = password_reset_tokens_crud.delete_expired_password_reset_tokens(
db
)
# Log the number of deleted tokens
if num_deleted > 0:
core_logger.print_to_log_and_console(
f"Deleted {num_deleted} expired password reset tokens", "info"
)
finally:
# Ensure the session is closed after use
db.close()

View File

@@ -43,4 +43,4 @@ class UsersSessions(Base):
)
# Define a relationship to the User model
user = relationship("User", back_populates="users_sessions")
user = relationship("User", back_populates="users_sessions")

View File

@@ -14,7 +14,6 @@ from sqlalchemy.orm import Session
import session.utils as session_utils
import session.security as session_security
import session.constants as session_constants
import session.crud as session_crud
import session.schema as session_schema

View File

@@ -70,7 +70,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
# Define paths that don't need CSRF protection
self.exempt_paths = ["/api/v1/token", "/api/v1/refresh", "/api/v1/mfa/verify"]
self.exempt_paths = [
"/api/v1/token",
"/api/v1/refresh",
"/api/v1/mfa/verify",
"/api/v1/password-reset/request",
"/api/v1/password-reset/confirm",
]
async def dispatch(self, request: Request, call_next):
# Get client type from header

View File

@@ -14,7 +14,6 @@ import users.user_integrations.schema as user_integrations_schema
import users.user_integrations.crud as user_integrations_crud
import users.user.crud as users_crud
import users.user.schema as user_schema
from core.database import SessionLocal

View File

@@ -95,6 +95,12 @@ class User(Base):
back_populates="user",
cascade="all, delete-orphan",
)
# Define a relationship to PasswordResetToken model
password_reset_tokens = relationship(
"PasswordResetToken",
back_populates="user",
cascade="all, delete-orphan",
)
# Define a relationship to UsersIntegrations model
users_integrations = relationship(
"UsersIntegrations",

120
backend/poetry.lock generated
View File

@@ -52,6 +52,31 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
trio = ["trio (>=0.26.1)"]
[[package]]
name = "apprise"
version = "1.9.4"
description = "Push Notifications that work with just about every platform!"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "apprise-1.9.4-py3-none-any.whl", hash = "sha256:17dca8ad0a5b2063f6bae707979a51ca2cb374fcc66b0dd5c05a9d286dd40069"},
{file = "apprise-1.9.4.tar.gz", hash = "sha256:483122aee19a89a7b075ecd48ef11ae37d79744f7aeb450bcf985a9a6c28c988"},
]
[package.dependencies]
certifi = "*"
click = ">=5.0"
markdown = "*"
PyYAML = "*"
requests = "*"
requests-oauthlib = "*"
[package.extras]
all-plugins = ["PGPy", "cryptography", "gntp", "paho-mqtt (!=2.0.*)", "smpplib"]
dev = ["babel", "coverage", "mock", "pytest", "pytest-cov", "pytest-mock", "ruff", "tox", "validate-pyproject"]
windows = ["pywin32"]
[[package]]
name = "apscheduler"
version = "3.11.0"
@@ -1332,6 +1357,22 @@ babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]]
name = "markdown"
version = "3.8.2"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
{file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "markupsafe"
version = "3.0.2"
@@ -1405,14 +1446,14 @@ files = [
[[package]]
name = "more-itertools"
version = "10.7.0"
version = "10.8.0"
description = "More routines for operating on iterables, beyond itertools"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e"},
{file = "more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3"},
{file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"},
{file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"},
]
[[package]]
@@ -1828,14 +1869,14 @@ files = [
[[package]]
name = "pbs-installer"
version = "2025.8.28"
version = "2025.9.2"
description = "Installer for Python Build Standalone"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pbs_installer-2025.8.28-py3-none-any.whl", hash = "sha256:a42cd2532dbac5dc836db41cf1bc5ed26ef275214e8e30e35aff961883997048"},
{file = "pbs_installer-2025.8.28.tar.gz", hash = "sha256:3accb1a184a048e657323c17d1c48b2969e49501b165e7200a520af7022d9bb0"},
{file = "pbs_installer-2025.9.2-py3-none-any.whl", hash = "sha256:659a5399278c810761c1e7bc54095f38af11a5b593ce8d45c41a3a9d6759d8f1"},
{file = "pbs_installer-2025.9.2.tar.gz", hash = "sha256:0da1d59bb5c4d8cfb5aee29ac2a37b37d651a45ab5ede19d1331df9a92464b5d"},
]
[package.dependencies]
@@ -2464,6 +2505,69 @@ files = [
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
]
[[package]]
name = "pyyaml"
version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
{file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
{file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
{file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
{file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
{file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
{file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
{file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
{file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
{file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
{file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
{file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
{file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
{file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
{file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
[[package]]
name = "qrcode"
version = "8.2"
@@ -3466,5 +3570,5 @@ cffi = ["cffi (>=1.17)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "4dc675352bb11a60f0f410a71e3c393d0b7c97714bbdb2ffdaee2cf25764606b"
python-versions = "^3.13"
content-hash = "43875600d84afa2ad5c6b5fbd55fa84c74b90859c1c3875b7df8599aca2e6f9c"

View File

@@ -39,6 +39,7 @@ poetry = "^2.0.0"
user-agents = "^2.2.0"
pydantic = {extras = ["email"], version = "^2.11.3"}
garth = "^0.5.16"
apprise = "^1.8.0"
pyotp = "^2.9.0"
qrcode = {extras = ["pil"], version = "^8.2"}

View File

@@ -44,6 +44,12 @@ Table below shows supported environment variables. Variables marked with optiona
| JAEGER_PORT | 4317 | Yes | N/A |
| BEHIND_PROXY | false | Yes | Change to true if behind reverse proxy |
| ENVIRONMENT | production | Yes | "production" and "development" allowed. "development" allows connections from localhost:8080 and localhost:5173 at the CORS level |
| SMTP_HOST | No default set | Yes | The SMTP host of your email provider. Example `smtp.protonmail.ch` |
| SMTP_PORT | 587 | Yes | The SMTP port of your email provider. Default is 587 |
| SMTP_USERNAME | No default set | Yes | The username of your SMTP email provider, probably your email address |
| SMTP_PASSWORD | No default set | Yes | The password of your SMTP email provider. Some providers allow the use of your account password, others require the creation of an app password. Please refer to your provider documentation |
| SMTP_SECURE | true | Yes | By default it uses secure communications. Accepted values are `true` and `false` |
| SMTP_SECURE_TYPE | starttls | Yes | If SMTP_SECURE is set you can set the communication type. Accepted values are `starttls` and `ssl` |
Table below shows the obligatory environment variables for mariadb container. You should set them based on what was also set for the Endurain container.

View File

@@ -29,7 +29,7 @@
Endurain is a self-hosted fitness tracking service designed to give users full control over their data and hosting environment. Built with:
- **Frontend:** Vue.js, Notivue and Bootstrap CSS
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, Apprise, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
- **Database:** MariaDB or PostgreSQL for efficient data management
- **Observability:** Jaeger for basic tracing and monitoring
@@ -64,6 +64,7 @@ Endurain currently supports:
- Notification system
- Define and track goals
- MFA TOTP support
- Password reset through email link. Uses Apprise for email notifications
## Planned Features

View File

@@ -0,0 +1,117 @@
<template>
<div
class="modal fade"
:id="`${modalId}`"
tabindex="-1"
:aria-labelledby="`${modalId}`"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" :id="`${modalId}`">{{ title }}</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<label :for="`${modalId}Email`" class="form-label">{{ emailFieldLabel }}</label>
<input
type="email"
class="form-control"
:class="{ 'is-invalid': !isEmailValid }"
:name="`${modalId}Email`"
:id="`${modalId}Email`"
v-model="emailToEmit"
:placeholder="emailFieldLabel"
required
/>
<div id="validationEmailFeedback" class="invalid-feedback" v-if="!isEmailValid">
{{ $t('usersAddEditUserModalComponent.addEditUserModalErrorEmailInvalid') }}
</div>
<div class="form-text" v-if="emailHelpText">{{ emailHelpText }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ $t('generalItems.buttonClose') }}
</button>
<a
type="button"
@click="submitAction()"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
'btn-danger': actionButtonType === 'danger',
'btn-warning': actionButtonType === 'warning',
'btn-primary': actionButtonType === 'loading'
}"
:disabled="isLoading"
><span
v-if="isLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span
>{{ actionButtonText }}</a
>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modalId: {
type: String,
required: true
},
title: {
type: String,
required: true
},
emailFieldLabel: {
type: String,
required: true
},
emailHelpText: {
type: String,
default: ''
},
emailDefaultValue: {
type: String,
default: ''
},
actionButtonType: {
type: String,
required: true
},
actionButtonText: {
type: String,
required: true
},
isLoading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['emailToEmitAction'])
const isEmailValid = computed(() => {
const emailRegex = /^[^\s@]{1,}@[^\s@]{2,}\.[^\s@]{2,}$/
return emailRegex.test(emailToEmit.value)
})
const emailToEmit = ref(props.emailDefaultValue)
function submitAction() {
if (emailToEmit.value) {
emit('emailToEmitAction', emailToEmit.value)
}
}
</script>

View File

@@ -1,9 +1,52 @@
<template>
<div class="col">
<div class="bg-body-tertiary rounded p-3 shadow-sm">
<h4>{{ $t('settingsSecurityZone.subtitleChangePassword') }}</h4>
<UsersPasswordRequirementsComponent />
<div class="col">
<div class="bg-body-tertiary rounded p-3 shadow-sm">
<h4>{{ $t('settingsSecurityZone.subtitleChangePassword') }}</h4>
<UsersPasswordRequirementsComponent />
<form @submit.prevent="submitChangeUserPasswordForm">
<!-- password fields -->
<label for="validationNewPassword"
><b>* {{ $t('settingsSecurityZone.changeUserPasswordPasswordLabel') }}</b></label
>
<div class="position-relative">
<input
:type="showNewPassword ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': !isNewPasswordValid || !isPasswordMatch }"
id="validationNewPassword"
aria-describedby="validationNewPasswordFeedback"
:placeholder="$t('settingsSecurityZone.changeUserPasswordPasswordLabel')"
v-model="newPassword"
required
/>
<button
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y"
:class="{ 'me-4': !isNewPasswordValid || !isPasswordMatch }"
@click="toggleNewPasswordVisibility"
>
<font-awesome-icon :icon="showNewPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']" />
</button>
</div>
<div
id="validationNewPasswordFeedback"
class="invalid-feedback d-block"
v-if="!isNewPasswordValid"
>
{{ $t('settingsSecurityZone.changeUserPasswordFeedbackLabel') }}
</div>
<div
id="validationNewPasswordFeedback"
class="invalid-feedback d-block"
v-if="!isPasswordMatch"
>
{{ $t('settingsSecurityZone.changeUserPasswordPasswordsDoNotMatchFeedbackLabel') }}
</div>
<form @submit.prevent="submitChangeUserPasswordForm">
<!-- password fields -->
<label for="validationNewPassword"
@@ -294,8 +337,10 @@ import { useI18n } from 'vue-i18n'
import Modal from 'bootstrap/js/src/modal'
// Importing the services
import { profile } from '@/services/profileService'
import { profile } from '@/services/profileService'
// Import Notivue push
import { push } from 'notivue'
import { push } from 'notivue'
// Importing the components
import UsersPasswordRequirementsComponent from '@/components/Settings/SettingsUsersZone/UsersPasswordRequirementsComponent.vue'
import LoadingComponent from '@/components/GeneralComponents/LoadingComponent.vue'

View File

@@ -56,6 +56,20 @@
<!-- Displaying loading if users are updating -->
<LoadingComponent v-if="isUsersUpdatingLoading" />
<!-- list zone -->
<ul
class="list-group list-group-flush"
v-for="user in usersArray"
:key="user.id"
:user="user"
v-else
>
<UsersListComponent
:user="user"
@userDeleted="updateUserList"
@editedUser="editUserList"
/>
</ul>
<!-- list zone -->
<ul
class="list-group list-group-flush"
@@ -102,6 +116,17 @@ import PaginationComponent from '@/components/GeneralComponents/PaginationCompon
import UsersAddEditUserModalComponent from '@/components/Settings/SettingsUsersZone/UsersAddEditUserModalComponent.vue'
import { users } from '@/services/usersService'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
import { ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { push } from 'notivue'
import { debounce } from 'lodash'
import LoadingComponent from '@/components/GeneralComponents/LoadingComponent.vue'
import NoItemsFoundComponent from '@/components/GeneralComponents/NoItemsFoundComponents.vue'
import UsersListComponent from '@/components/Settings/SettingsUsersZone/UsersListComponent.vue'
import PaginationComponent from '@/components/GeneralComponents/PaginationComponent.vue'
import UsersAddEditUserModalComponent from '@/components/Settings/SettingsUsersZone/UsersAddEditUserModalComponent.vue'
import { users } from '@/services/usersService'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
const { t } = useI18n()
const serverSettingsStore = useServerSettingsStore()

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -97,7 +97,8 @@ const componentPaths = {
searchView: 'searchView.json',
settingsView: 'settingsView.json',
userView: 'userView.json',
summaryView: 'summaryView.json'
summaryView: 'summaryView.json',
resetPassword: 'resetPassword.json'
}
// Reverse map: relative path -> semantic key

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -17,5 +17,17 @@
"signUpText": "Looking for signing up?",
"signUpButton": "Sign up",
"errorPublicActivityNotFound": "Public activity not found",
"errorpublic_shareable_links": "Public shareable links are not allowed. To view this activity, you must be signed in"
"errorPublic_shareable_links": "Public shareable links are not allowed. To view this activity, you must be signed in",
"forgotPassword": "Forgot your password?",
"passwordResetInvalidLink": "No password reset token provided",
"passwordResetSuccess": "Your password has been reset successfully",
"forgotPasswordModalTitle": "Forgot Password",
"forgotPasswordModalEmailLabel": "Email address",
"forgotPasswordModalEmailHelp": "Enter the email address associated with your account. An email with a link to reset your password will be sent.",
"forgotPasswordModalSubmitButton": "Send Reset Link",
"forgotPasswordModalEmailRequired": "Email address is required",
"forgotPasswordModalRequestSuccess": "If the email exists in the system, a password reset link will be sent to the provided email address",
"forgotPasswordModalRequestError": "Failed to process password reset request",
"forgotPasswordModalEmailNotConfigured": "Email service is not configured. Please contact the administrator",
"forgotPasswordModalUnableToSendEmail": "Unable to send email. Please try again later or contact the administrator"
}

View File

@@ -0,0 +1,11 @@
{
"title": "Reset Password",
"newPasswordLabel": "New Password",
"confirmPasswordLabel": "Confirm New Password",
"submitButton": "Reset Password",
"backToLogin": "Back to Login",
"passwordComplexityError": "Password must be at least 8 characters long, include an uppercase letter, a number, and a special character",
"passwordMismatchError": "Passwords do not match",
"resetError": "Failed to reset password",
"invalidOrExpiredToken": "Invalid or expired password reset token"
}

View File

@@ -15,6 +15,12 @@ const routes = [
component: () => import('../views/LoginView.vue'),
meta: { requiresAuth: false }
},
{
path: '/reset-password',
name: 'reset-password',
component: () => import('../views/ResetPasswordView.vue'),
meta: { requiresAuth: false }
},
{
path: '/gears',
name: 'gears',

View File

@@ -0,0 +1,10 @@
import { fetchPostRequest } from '@/utils/serviceUtils'
export const passwordReset = {
requestPasswordReset(data) {
return fetchPostRequest('password-reset/request', data)
},
confirmPasswordReset(data) {
return fetchPostRequest('password-reset/confirm', data)
}
}

View File

@@ -20,5 +20,11 @@ export const session = {
},
logoutUser() {
return fetchPostRequest('logout', null)
},
requestPasswordReset(data) {
return fetchPostRequest('password-reset/request', data)
},
confirmPasswordReset(data) {
return fetchPostRequest('password-reset/confirm', data)
}
}

View File

@@ -7,17 +7,19 @@ export const API_URL = `${window.env.ENDURAIN_HOST}/api/v1/`
export const FRONTEND_URL = `${window.env.ENDURAIN_HOST}/`
async function fetchWithRetry(url, options, responseType = 'json') {
// Add CSRF token to headers for state-changing requests
if (
['POST', 'PUT', 'DELETE', 'PATCH'].includes(options.method) &&
url !== 'token' &&
url !== 'refresh' &&
url !== 'mfa/verify'
) {
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('endurain_csrf_token='))
?.split('=')[1]
// Add CSRF token to headers for state-changing requests
if (
['POST', 'PUT', 'DELETE', 'PATCH'].includes(options.method) &&
url !== 'token' &&
url !== 'refresh' &&
url !== 'mfa/verify' &&
url !== 'password-reset/request' &&
url !== 'password-reset/confirm'
) {
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('endurain_csrf_token='))
?.split('=')[1]
if (csrfToken) {
options.headers = {

View File

@@ -90,11 +90,24 @@
</div>
</div>
</div>
<!-- Forgot Password Modal -->
<ModalComponentEmailInput
ref="forgotPasswordModalRef"
modal-id="forgotPasswordModal"
:title="$t('loginView.forgotPasswordModalTitle')"
:email-field-label="$t('loginView.forgotPasswordModalEmailLabel')"
:email-help-text="$t('loginView.forgotPasswordModalEmailHelp')"
action-button-type="success"
:action-button-text="$t('loginView.forgotPasswordModalSubmitButton')"
:is-loading="forgotPasswordLoading"
@emailToEmitAction="handleForgotPasswordSubmit"
/>
</template>
<script setup>
// Importing the vue composition API
import { ref, onMounted } from 'vue'
import { ref, onMounted, nextTick, watch } from 'vue'
// Importing the router
import { useRoute, useRouter } from 'vue-router'
// Importing the i18n
@@ -106,7 +119,11 @@ import { useAuthStore } from '@/stores/authStore'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
// Importing the services for the login
import { session } from '@/services/sessionService'
import { passwordReset } from '@/services/passwordResetService'
import { profile } from '@/services/profileService'
import { Modal } from 'bootstrap'
// Importing modal component
import ModalComponentEmailInput from '@/components/Modals/ModalComponentEmailInput.vue'
// Variables
const route = useRoute()
@@ -125,6 +142,18 @@ const loginPhotoUrl = serverSettingsStore.serverSettings.login_photo_set
? `${window.env.ENDURAIN_HOST}/server_images/login.png`
: null
// Forgot password variables
const forgotPasswordLoading = ref(false)
const forgotPasswordModalRef = ref(null)
let forgotPasswordModalInstance = null
// Function to show forgot password modal
const showForgotPasswordModal = () => {
if (forgotPasswordModalInstance) {
forgotPasswordModalInstance.show()
}
}
// Toggle password visibility
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value
@@ -200,24 +229,64 @@ const completeLogin = async (session_id) => {
// Store the user in the auth store
authStore.setUser(userProfile, session_id, locale)
// Redirect to the home page
return router.push('/')
}
// Handle login errors
const handleLoginError = (error) => {
if (error.toString().includes('401')) {
push.error(`${t('loginView.error401')} - ${error}`)
} else if (error.toString().includes('403')) {
push.error(`${t('loginView.error403')} - ${error}`)
} else if (error.toString().includes('500')) {
push.error(`${t('loginView.error500')} - ${error}`)
} else {
push.error(`${t('loginView.errorUndefined')} - ${error}`)
// Redirect to the home page
return router.push('/')
} catch (error) {
// Handle the error
if (error.toString().includes('401')) {
push.error(`${t('loginView.error401')} - ${error}`)
} else if (error.toString().includes('403')) {
push.error(`${t('loginView.error403')} - ${error}`)
} else if (error.toString().includes('500')) {
push.error(`${t('loginView.error500')} - ${error}`)
} else {
push.error(`${t('loginView.errorUndefined')} - ${error}`)
}
}
}
onMounted(() => {
// Forgot password form submission
const handleForgotPasswordSubmit = async (email) => {
if (!email) {
push.error(t('loginView.forgotPasswordModalEmailRequired'))
return
}
forgotPasswordLoading.value = true
try {
await passwordReset.requestPasswordReset({
email: email
})
push.success(t('loginView.forgotPasswordModalRequestSuccess'))
} catch (error) {
if (error.toString().includes('500')) {
push.error(t('loginView.forgotPasswordModalUnableToSendEmail'))
} else if (error.toString().includes('503')) {
push.error(t('loginView.forgotPasswordModalEmailNotConfigured'))
} else {
push.error(`${t('loginView.forgotPasswordModalRequestError')} - ${error}`)
}
} finally {
forgotPasswordLoading.value = false
// Close modal
if (forgotPasswordModalInstance) {
forgotPasswordModalInstance.hide()
}
}
}
onMounted(async () => {
// Initialize the modal
await nextTick()
if (forgotPasswordModalRef.value) {
// Access the modal element from the component
const modalElement = forgotPasswordModalRef.value.$el
forgotPasswordModalInstance = new Modal(modalElement)
}
// Check if the session expired
if (route.query.sessionExpired === 'true') {
push.warning(t('loginView.sessionExpired'))
@@ -232,7 +301,15 @@ onMounted(() => {
}
// Check if the public shareable links are disabled
if (route.query.errorpublic_shareable_links === 'true') {
push.error(t('loginView.errorpublic_shareable_links'))
push.error(t('loginView.errorPublic_shareable_links'))
}
// Check for password reset success
if (route.query.passwordResetSuccess === 'true') {
push.success(t('loginView.passwordResetSuccess'))
}
// Check for password reset invalid link
if (route.query.passwordResetInvalidLink === 'true') {
push.error(t('loginView.passwordResetInvalidLink'))
}
})
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div class="px-3">
<div class="row justify-content-center">
<div class="col-md-6 p-3 rounded bg-body-tertiary shadow-sm">
<h1>{{ $t('resetPassword.title') }}</h1>
<form @submit.prevent="submitResetForm">
<div class="mb-3">
<label for="newPassword" class="form-label">{{
$t('resetPassword.newPasswordLabel')
}}</label>
<div class="position-relative">
<input
:type="showNewPassword ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': !isNewPasswordValid && newPassword }"
id="newPassword"
v-model="newPassword"
required
/>
<button
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y me-2"
@click="toggleNewPasswordVisibility"
>
<font-awesome-icon
:icon="showNewPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
/>
</button>
</div>
<div v-if="!isNewPasswordValid && newPassword" class="invalid-feedback d-block">
{{ $t('resetPassword.passwordComplexityError') }}
</div>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">{{
$t('resetPassword.confirmPasswordLabel')
}}</label>
<div class="position-relative">
<input
:type="showConfirmPassword ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': !isPasswordMatch && confirmPassword }"
id="confirmPassword"
v-model="confirmPassword"
required
/>
<button
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y me-2"
@click="toggleConfirmPasswordVisibility"
>
<font-awesome-icon
:icon="showConfirmPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
/>
</button>
</div>
<div v-if="!isPasswordMatch && confirmPassword" class="invalid-feedback d-block">
{{ $t('resetPassword.passwordMismatchError') }}
</div>
</div>
<div class="d-grid gap-2">
<button
type="submit"
class="btn btn-primary"
:disabled="!isNewPasswordValid || !isPasswordMatch || resetLoading"
>
<span
v-if="resetLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t('resetPassword.submitButton') }}
</button>
<router-link to="/login" class="btn btn-secondary">
{{ $t('resetPassword.backToLogin') }}
</router-link>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { push } from 'notivue'
import { passwordReset } from '@/services/passwordResetService'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
// Form data
const newPassword = ref('')
const confirmPassword = ref('')
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const resetLoading = ref(false)
// Get token from query params
const token = route.query.token
// Password validation regex (same as backend)
const passwordRegex =
/^(?=.*[A-Z])(?=.*\d)(?=.*[ !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])[A-Za-z\d !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]{8,}$/
// Computed properties
const isNewPasswordValid = computed(() => {
if (!newPassword.value) return true
return passwordRegex.test(newPassword.value)
})
const isPasswordMatch = computed(() => {
if (!confirmPassword.value) return true
return newPassword.value === confirmPassword.value
})
// Methods
const toggleNewPasswordVisibility = () => {
showNewPassword.value = !showNewPassword.value
}
const toggleConfirmPasswordVisibility = () => {
showConfirmPassword.value = !showConfirmPassword.value
}
const submitResetForm = async () => {
if (!isNewPasswordValid.value || !isPasswordMatch.value) {
return
}
resetLoading.value = true
try {
await passwordReset.confirmPasswordReset({
token: token,
new_password: newPassword.value
})
// Redirect to login with success message
router.push('/login?passwordResetSuccess=true')
} catch (error) {
if (error.toString().includes('400')) {
push.error(t('resetPassword.invalidOrExpiredToken'))
} else {
push.error(`${t('resetPassword.resetError')} - ${error}`)
}
} finally {
resetLoading.value = false
}
}
onMounted(() => {
// Check if token is provided
if (!token) {
router.push('/login?passwordResetInvalidLink=true')
}
})
</script>