Add user IdP linking and MFA setup modals

Implements endpoints and frontend modals for users to link/unlink external identity providers (IdPs) to their accounts, including backend logic for secure OAuth linking and session management. Adds ModalComponentMFASetup for multi-factor authentication setup, refactors modal input components for accessibility and consistency, and updates documentation and trademark policy. Adjusts scope constants and permissions for identity provider management.
This commit is contained in:
João Vitória Silva
2025-10-17 16:19:57 +01:00
parent f0cb46ab98
commit 5d2c783460
26 changed files with 2006 additions and 577 deletions

View File

@@ -7,6 +7,7 @@
![License](https://img.shields.io/github/license/joaovitoriasilva/endurain)
[![GitHub release](https://img.shields.io/github/v/release/joaovitoriasilva/endurain)](https://github.com/joaovitoriasilva/endurain/releases)
[![GitHub stars](https://img.shields.io/github/stars/joaovitoriasilva/endurain.svg?style=social&label=Star)](https://github.com/joaovitoriasilva/endurain/stargazers)
[![Trademark Policy](https://img.shields.io/badge/trademark-Endurain%E2%84%A2-blue)](./TRADEMARK.md)
**A self-hosted fitness tracking service**
Visit Endurain's [Mastodon profile](https://fosstodon.org/@endurain) and [Discord server](https://discord.gg/6VUjUq2uZR).
@@ -54,4 +55,13 @@ Endurain has multi-language support, and you can help translate it into more lan
## License
This project is licensed under the AGPL-3.0 License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the AGPL-3.0 License - see the [LICENSE](LICENSE) file for details.
**Trademark Notice:**
Endurain™ is a trademark of João Vitória Silva.
An application for registration with the European Union Intellectual Property Office (EUIPO) is currently pending.
You are welcome to self-host Endurain — including for personal, educational, research, or community (non-commercial) use — using the name and logo.
Commercial use of the Endurain name or Logos (such as offering paid hosting, products, or services) is **not permitted without prior written permission**.
See [`TRADEMARK.md`](./TRADEMARK.md) for full details.

View File

@@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 0.13.X | :white_check_mark: |
| 0.12.X and earlier | :x: |
| 0.16.X | :white_check_mark: |
| 0.15.X and earlier | :x: |
## Reporting a Vulnerability

96
TRADEMARK.md Normal file
View File

@@ -0,0 +1,96 @@
# Endurain Trademark Policy
_Last updated: 2025-10-17_
"**Endurain**" (the **Word Mark**) is a claimed trademark of **João Vitória Silva** (the **Owner**).
An application to register this word mark with the European Union Intellectual Property Office (EUIPO) is currently pending.
Until registration is granted, the mark should be referred to as **Endurain™**.
The **Endurain logo** and related visual identity elements (the **Logos**) are original design works owned by João Vitória Silva and protected by copyright and design rights, but they are **not yet registered as separate trademarks**.
This policy explains when and how you may use the Endurain name and Logos.
It exists alongside the softwares license (AGPLv3). **The AGPLv3 covers the code; this policy covers the branding.**
## 1. Philosophy
Endurain is open source and encourages self-hosting.
I want individuals, communities, and non-profits to freely use the **Endurain™** name and logo for non-commercial purposes.
At the same time, I reserve the right to control **commercial use** of the brand.
## 2. Permitted Use Without Permission
You **may** use the Endurain™ name and Logos in the following cases:
- **Personal self-hosting** — running Endurain for yourself, friends, or family.
- **Community or club hosting** — for sports clubs, local groups, or other non-profit efforts.
- **Educational use** — teaching, research, or academic projects.
- **Open-source contributions** — forks, test deployments, and integrations, as long as they are not sold.
- **Nominative references** — talking about, writing about, or demonstrating Endurain truthfully.
These uses may retain the name **Endurain™** and the official Logos.
**Rules for non-commercial use:**
- Do not imply that your use is officially sponsored or endorsed by the Endurain project.
- Do not alter the Logos except for reasonable resizing or adapting to backgrounds.
- Always comply with the AGPLv3 license for the code.
- Make it clear if your instance or fork is not the official Endurain service.
## 3. Uses That Require Permission
The following uses of the Endurain™ name or Logos require **prior written permission** from the Owner:
- Offering **paid hosting**, **SaaS**, or commercial cloud services under the Endurain name.
- Selling products or services that prominently use the Endurain name or Logos.
- Using the Endurain name in a **company name, brand, or product line**.
- Marketing **commercial support, consulting, or training** under the Endurain name.
- Registering domains or social media handles that imply official status (e.g., `enduraincloud.com`, `@endurainapp`).
- Producing merchandise (shirts, stickers, etc.) with the Endurain name or Logos for sale.
For permission requests, contact: [joao@endurain.com](mailto:joao@endurain.com).
## 4. Forks and Distributions
I welcome forks and distributions under the AGPLv3 license.
If your fork is **non-commercial**, you may keep the Endurain™ name and Logos, provided you:
- Clearly state that it is **not the official Endurain project**.
- Identify it as “a fork of Endurain” or “based on Endurain.”
- Do not present yourself as an official maintainer or partner.
If your fork is **commercial** (offering paid hosting, subscriptions, or products), you **must rename it** and **remove the Logos** unless you have written permission.
## 5. Packages, Domains, and Namespaces
- **Package registries** (npm, PyPI, Docker, etc.): Non-commercial packages may use “Endurain” in the name.
Commercial or paid offerings may not without permission.
- **Domains and handles:** Avoid names that could be mistaken for the official project unless approved.
## 6. Prohibited Uses
The Endurain™ name and Logos may **never** be used:
- In any misleading way that implies endorsement or partnership.
- In connection with illegal activities, malware, or harmful content.
- To promote or brand competing software as “Endurain” without permission.
- To sell any service or product under the Endurain name without authorization.
## 7. License vs. Trademark
The AGPLv3 license gives you broad rights to use, modify, and distribute the **code**, including commercially.
This trademark policy only limits how you use the **name and branding**.
You are free to offer commercial services based on the Endurain code — but you must not market them **under the Endurain name or Logos** unless permitted.
## 8. Attribution
When referring to Endurain in permitted use, include the following notice:
> "Endurain™ is a trademark of João Vitória Silva. Registration pending."
## 9. Reporting Misuse
If you believe the Endurain trademark or branding is being misused, please contact: [joao@endurain.com](mailto:joao@endurain.com).
## 10. Changes
This policy may be updated periodically. Any changes will be documented in this file.

View File

@@ -104,25 +104,30 @@ async def handle_callback(
response: Response = None,
):
"""
Handles the OAuth callback from an external Identity Provider (IdP) for Single Sign-On (SSO) authentication.
Rate Limit: 10 requests per minute per IP
This endpoint processes the authorization code and state parameters returned by the IdP after user authentication.
It validates the IdP, exchanges the code for user information, creates session tokens, stores the session in the database,
sets authentication cookies, and redirects the user to the frontend application.
Handle OAuth callback from an identity provider.
This endpoint processes the OAuth authorization callback from external identity providers.
It supports two modes: login mode (default) and link mode (for linking IdP to existing account).
Args:
idp_slug (str): The slug identifier for the identity provider.
code (str): Authorization code received from the IdP.
state (str): State parameter for CSRF protection.
request (Request, optional): The incoming HTTP request object.
response (Response, optional): The outgoing HTTP response object.
password_hasher (session_password_hasher.PasswordHasher, optional): Dependency-injected password hasher for secure password handling.
token_manager (session_token_manager.TokenManager, optional): Dependency-injected token manager for session handling.
db (Session, optional): Dependency-injected database session.
idp_slug (str): The slug identifier of the identity provider.
password_hasher (session_password_hasher.PasswordHasher): Password hasher dependency for session management.
token_manager (session_token_manager.TokenManager): Token manager dependency for creating session tokens.
db (Session): Database session dependency.
code (str): Authorization code received from the identity provider.
state (str): State parameter used for CSRF protection.
request (Request, optional): The incoming HTTP request. Defaults to None.
response (Response, optional): The HTTP response object. Defaults to None.
Returns:
RedirectResponse: Redirects the user to the frontend application with success or error status.
RedirectResponse: A redirect response to either:
- Settings page (link mode): /settings/security with success parameters
- Login page (login mode): /login with session_id
- Error page: /login with error parameter if callback fails
Raises:
HTTPException: If the identity provider is not found or disabled, or if other errors occur during processing.
HTTPException: If the identity provider is not found, disabled, or if callback processing fails.
Notes:
- In link mode: Redirects to settings without creating a new session
- In login mode: Creates session tokens, stores session in database, sets authentication cookies
- On error: Redirects to login page with error parameter
- All redirects use HTTP 307 (Temporary Redirect) status code
"""
try:
# Get the identity provider
@@ -139,7 +144,23 @@ async def handle_callback(
)
user = result["user"]
is_link_mode = result.get("mode") == "link"
# Handle link mode differently - redirect to settings without creating new session
if is_link_mode:
frontend_url = core_config.ENDURAIN_HOST
redirect_url = f"{frontend_url}/settings/security?idp_link=success&idp_name={idp.name}"
core_logger.print_to_log(
f"IdP link successful for user {user.username}, IdP {idp.name}", "info"
)
return RedirectResponse(
url=redirect_url,
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
# LOGIN MODE: Create session and redirect to dashboard
# Convert to UserRead schema
user_read = users_schema.UserRead.model_validate(user)

View File

@@ -29,7 +29,7 @@ async def list_identity_providers(
db: Annotated[Session, Depends(core_database.get_db)],
_check_scopes: Annotated[
users_schema.UserRead,
Security(session_security.check_scopes, scopes=["server_settings:read"]),
Security(session_security.check_scopes, scopes=["identity_providers:read"]),
],
):
"""
@@ -53,7 +53,7 @@ async def list_identity_providers(
async def list_idp_templates(
_check_scopes: Annotated[
users_schema.UserRead,
Security(session_security.check_scopes, scopes=["server_settings:read"]),
Security(session_security.check_scopes, scopes=["identity_providers:read"]),
],
):
"""
@@ -72,7 +72,7 @@ async def create_identity_provider(
db: Annotated[Session, Depends(core_database.get_db)],
_check_scopes: Annotated[
users_schema.UserRead,
Security(session_security.check_scopes, scopes=["server_settings:write"]),
Security(session_security.check_scopes, scopes=["identity_providers:write"]),
],
):
"""
@@ -103,7 +103,7 @@ async def update_identity_provider(
db: Annotated[Session, Depends(core_database.get_db)],
_check_scopes: Annotated[
users_schema.UserRead,
Security(session_security.check_scopes, scopes=["server_settings:write"]),
Security(session_security.check_scopes, scopes=["identity_providers:write"]),
],
):
"""
@@ -129,7 +129,7 @@ async def delete_identity_provider(
idp_id: int,
_check_scopes: Annotated[
users_schema.UserRead,
Security(session_security.check_scopes, scopes=["server_settings:write"]),
Security(session_security.check_scopes, scopes=["identity_providers:write"]),
],
db: Annotated[Session, Depends(core_database.get_db)],
):

View File

@@ -748,6 +748,103 @@ class IdentityProviderService:
detail="Failed to initiate SSO login",
) from err
async def initiate_link(
self, idp: idp_models.IdentityProvider, request: Request, user_id: int, db: Session
) -> str:
"""
Initiates the OAuth/OIDC authorization flow for linking an identity provider to an existing user account.
This method generates the authorization URL that redirects the user to the identity provider's
login page. It creates secure state and nonce tokens to prevent CSRF attacks and stores session
data to track the linking operation.
Args:
idp (idp_models.IdentityProvider): The identity provider configuration object containing
client credentials, endpoints, and other OAuth/OIDC settings.
request (Request): The FastAPI request object used to access and store session data.
user_id (int): The ID of the authenticated user who is linking their account to the
identity provider.
db (Session): The database session for potential database operations.
Returns:
str: The authorization URL to redirect the user to for identity provider authentication.
Raises:
HTTPException:
- 500 status code if the identity provider is not properly configured (missing
authorization endpoint).
- 500 status code if any unexpected error occurs during the OAuth flow initiation.
Note:
- If authorization_endpoint is not directly configured, the method attempts OIDC
discovery using the issuer_url.
- State data includes: random token, timestamp, idp_id, mode flag ('link'), and user_id.
- State is base64-encoded for URL safety.
- Session stores: oauth_state, oauth_nonce, oauth_idp_id, and oauth_link_user_id.
"""
try:
client_id = idp.client_id
# Get endpoints
authorization_endpoint = idp.authorization_endpoint
# Try OIDC discovery if issuer URL is provided
if not authorization_endpoint and idp.issuer_url:
config = await self.get_oidc_configuration(idp)
if config:
authorization_endpoint = config.get("authorization_endpoint")
if not authorization_endpoint:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Identity provider not properly configured",
)
# Generate state and nonce for security
# State includes timestamp, mode flag, and user_id for link mode
random_state = secrets.token_urlsafe(32)
state_data = {
"random": random_state,
"timestamp": datetime.now(timezone.utc).isoformat(),
"idp_id": idp.id,
"mode": "link", # Indicates link mode (vs login mode)
"user_id": user_id, # Ensures callback links to correct user
}
# Encode state as base64 JSON for URL safety
state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
nonce = secrets.token_urlsafe(32)
# Store in session (using SessionMiddleware)
request.session[f"oauth_state_{idp.id}"] = state
request.session[f"oauth_nonce_{idp.id}"] = nonce
request.session["oauth_idp_id"] = idp.id
request.session["oauth_link_user_id"] = user_id # Track linking user
# Build authorization URL
redirect_uri = self._get_redirect_uri(idp.slug)
scopes = idp.scopes or "openid profile email"
client = AsyncOAuth2Client(
client_id=client_id, redirect_uri=redirect_uri, scope=scopes
)
authorization_url, _ = client.create_authorization_url(
authorization_endpoint, state=state, nonce=nonce
)
return authorization_url
except HTTPException:
raise
except Exception as err:
core_logger.print_to_log(
f"Error initiating OAuth link for IdP {idp.name}, user {user_id}: {err}",
"error",
exc=err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to initiate identity provider linking",
) from err
async def handle_callback(
self,
idp: idp_models.IdentityProvider,
@@ -758,25 +855,41 @@ class IdentityProviderService:
db: Session,
) -> Dict[str, Any]:
"""
Handles the OAuth2/OIDC callback from an external Identity Provider (IdP).
This method verifies the state parameter to prevent CSRF attacks, exchanges the authorization code
for tokens, retrieves user information from the IdP, and finds or creates a corresponding user in the system.
Handle the OAuth2/OIDC callback from an identity provider.
This method processes the authorization code received from an identity provider,
validates the state parameter, exchanges the code for tokens, retrieves user
information, and either creates/updates a user session (login mode) or links
the identity provider to an existing user account (link mode).
Args:
idp (idp_models.IdentityProvider): The identity provider configuration object.
code (str): The authorization code returned by the IdP.
state (str): The state parameter returned by the IdP for CSRF protection.
request (Request): The incoming HTTP request object.
password_hasher (session_password_hasher.PasswordHasher): The password hasher instance.
db (Session): The database session for user lookup/creation.
code (str): The authorization code returned by the identity provider.
state (str): The state parameter for CSRF protection, containing JSON with
timestamp, mode, and optional user_id.
request (Request): The FastAPI/Starlette request object containing session data.
password_hasher (session_password_hasher.PasswordHasher): Password hasher instance
for user authentication operations.
db (Session): SQLAlchemy database session.
Returns:
Dict[str, Any]: A dictionary containing the authenticated user, token data, and userinfo.
Dict[str, Any]: A dictionary containing:
- user: The authenticated or linked user object
- token_data: OAuth2 token response (access_token, refresh_token, etc.)
- userinfo: User information claims from the identity provider
- mode (optional): "link" if this was a link operation (not present for login)
Raises:
HTTPException: If the state is invalid, the IdP is misconfigured, user identifier is missing,
or any other error occurs during the callback handling process.
HTTPException: With appropriate status codes for various error conditions:
- 400 BAD_REQUEST: Invalid/expired state, missing parameters, user ID mismatch
- 404 NOT_FOUND: User not found during link mode
- 409 CONFLICT: IdP account already linked to a user
- 500 INTERNAL_SERVER_ERROR: Unexpected errors during authentication
- 502 BAD_GATEWAY: IdP communication errors, invalid responses
- 504 GATEWAY_TIMEOUT: IdP not responding
Notes:
- State parameter must be valid and not older than 10 minutes (CSRF protection)
- In link mode, validates that the IdP account is not already linked to any user
- In login mode, creates new user accounts if they don't exist (SSO provisioning)
- Stores IdP tokens securely for future session renewal
- Performs ID token verification if JWKS URI is available
- Cleans up session state data after successful completion
"""
try:
# Verify state with timestamp expiry validation
@@ -831,6 +944,32 @@ class IdentityProviderService:
detail="Invalid state parameter format",
) from err
# Detect link mode from state data
is_link_mode = state_data.get("mode") == "link"
link_user_id = None
if is_link_mode:
# Validate link mode state
link_user_id = state_data.get("user_id")
session_link_user_id = request.session.get("oauth_link_user_id")
if not link_user_id or not session_link_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid link mode state - missing user ID",
)
if link_user_id != session_link_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User ID mismatch - possible session hijacking attempt",
)
core_logger.print_to_log(
f"Link mode detected for IdP {idp.name}, user_id={link_user_id}",
"debug"
)
# Decrypt credentials and resolve endpoints using helper methods
client_id = idp.client_id
client_secret = self._decrypt_client_secret(idp)
@@ -955,24 +1094,83 @@ class IdentityProviderService:
detail=f"Identity provider {idp.name} did not provide required user identifier. Please contact administrator.",
)
# Find or create user
user = await self._find_or_create_user(
idp, subject, userinfo, password_hasher, db
)
# Handle link mode differently from login mode
if is_link_mode:
# LINK MODE: Associate IdP with existing authenticated user
# Verify user exists
user = users_crud.get_user_by_id(link_user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if this IdP subject is already linked to ANY user
existing_link = user_idp_crud.get_user_by_idp(idp.id, subject, db)
if existing_link:
# Check if it's already linked to THIS user
if existing_link.user_id == link_user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"This {idp.name} account is already linked to your account",
)
else:
# Linked to a DIFFERENT user - security issue
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"This {idp.name} account is already linked to another user",
)
# Create the link
user_idp_crud.create_user_idp_link(
user_id=link_user_id,
idp_id=idp.id,
idp_subject=subject,
db=db
)
# Store IdP tokens for future use
await self._store_idp_tokens(link_user_id, idp.id, token_response, db)
# Clean up session data
request.session.pop(f"oauth_state_{idp.id}", None)
request.session.pop(f"oauth_nonce_{idp.id}", None)
request.session.pop("oauth_idp_id", None)
request.session.pop("oauth_link_user_id", None)
core_logger.print_to_log(
f"User {user.username} (id={link_user_id}) linked IdP {idp.name} (subject={subject})",
"info"
)
# Return special structure for link mode (no new session created)
return {
"user": user,
"token_data": token_response,
"userinfo": userinfo,
"mode": "link" # Indicate this was a link operation
}
else:
# LOGIN MODE: Find or create user and establish session
user = await self._find_or_create_user(
idp, subject, userinfo, password_hasher, db
)
# Store IdP tokens for future session renewal
await self._store_idp_tokens(user.id, idp.id, token_response, db)
# Store IdP tokens for future session renewal
await self._store_idp_tokens(user.id, idp.id, token_response, db)
# Clean up session data after successful authentication
request.session.pop(f"oauth_state_{idp.id}", None)
request.session.pop(f"oauth_nonce_{idp.id}", None)
request.session.pop("oauth_idp_id", None)
# Clean up session data after successful authentication
request.session.pop(f"oauth_state_{idp.id}", None)
request.session.pop(f"oauth_nonce_{idp.id}", None)
request.session.pop("oauth_idp_id", None)
core_logger.print_to_log(
f"User {user.username} authenticated via IdP {idp.name}", "info"
)
core_logger.print_to_log(
f"User {user.username} authenticated via IdP {idp.name}", "info"
)
return {"user": user, "token_data": token_response, "userinfo": userinfo}
return {"user": user, "token_data": token_response, "userinfo": userinfo}
except HTTPException:
# Re-raise HTTPExceptions as-is (already have proper status codes and messages)

View File

@@ -5,16 +5,22 @@ from io import BytesIO
import tempfile
import zipfile
from typing import Annotated
from typing import Annotated, List
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, Request
from fastapi.responses import StreamingResponse, RedirectResponse
from sqlalchemy.orm import Session
import users.user.schema as users_schema
import users.user.crud as users_crud
import users.user.utils as users_utils
import users.user_identity_providers.crud as user_idp_crud
import users.user_identity_providers.schema as user_idp_schema
import identity_providers.crud as idp_crud
import identity_providers.service as idp_service
import users.user_integrations.crud as user_integrations_crud
import users.user_integrations.schema as users_integrations_schema
@@ -32,6 +38,7 @@ import profile.schema as profile_schema
import session.security as session_security
import session.crud as session_crud
import session.password_hasher as session_password_hasher
import core.database as core_database
import core.config as core_config
@@ -275,6 +282,10 @@ async def edit_profile_password(
int,
Depends(session_security.get_sub_from_access_token),
],
password_hasher: Annotated[
session_password_hasher.PasswordHasher,
Depends(session_password_hasher.get_password_hasher),
],
db: Annotated[
Session,
Depends(core_database.get_db),
@@ -286,13 +297,16 @@ async def edit_profile_password(
Args:
user_attributtes (users_schema.UserEditPassword): Schema containing the new password.
token_user_id (int): ID of the user extracted from the access token.
password_hasher (session_password_hasher.PasswordHasher): Password hasher dependency.
db (Session): Database session dependency.
Returns:
dict: A message indicating the password was updated successfully.
"""
# Update the user password in the database
users_crud.edit_user_password(token_user_id, user_attributtes.password, db)
users_crud.edit_user_password(
token_user_id, user_attributtes.password, password_hasher, db
)
# Return success message
return {f"User ID {token_user_id} password updated successfully"}
@@ -1426,3 +1440,258 @@ async def verify_mfa(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid MFA code"
)
return {"message": "MFA code verified successfully"}
# Identity Provider Management Endpoints
@router.get(
"/idp",
response_model=List[user_idp_schema.UserIdentityProviderResponse],
status_code=status.HTTP_200_OK,
)
async def get_my_identity_providers(
token_user_id: Annotated[
int,
Depends(session_security.get_sub_from_access_token),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""
Retrieve all identity provider links for the authenticated user.
This endpoint fetches all external identity provider (IdP) connections associated
with the current user's account. Each link includes connection metadata and enriched
details about the identity provider (name, slug, icon, and provider type).
Args:
token_user_id (int): The authenticated user's ID extracted from the JWT access token.
Injected automatically via dependency injection.
db (Session): Database session for executing queries.
Injected automatically via dependency injection.
Returns:
list[dict]: A list of dictionaries representing the user's IdP links. Each dictionary contains:
- id (int): Unique identifier for the user-IdP link
- user_id (int): ID of the user
- idp_id (int): ID of the identity provider
- idp_subject (str): User's unique identifier at the IdP
- linked_at (datetime): Timestamp when the link was created
- last_login (datetime): Timestamp of the last login via this IdP
- idp_access_token_expires_at (datetime): Expiration time of the IdP access token
- idp_refresh_token_updated_at (datetime): Last update time of the refresh token
- idp_name (str): Display name of the identity provider (if available)
- idp_slug (str): URL-safe identifier for the IdP (if available)
- idp_icon (str): Icon/logo URL for the IdP (if available)
- idp_provider_type (str): Type of provider (e.g., "oauth2", "oidc") (if available)
Raises:
HTTPException: May raise authentication/authorization errors via the dependency injection.
"""
# Get user's IdP links
idp_links = user_idp_crud.get_user_idp_links(token_user_id, db)
# Enrich with IDP details (reuse logic from admin endpoint)
enriched_links = []
for link in idp_links:
# Convert SQLAlchemy model to dict
link_dict = {
"id": link.id,
"user_id": link.user_id,
"idp_id": link.idp_id,
"idp_subject": link.idp_subject,
"linked_at": link.linked_at,
"last_login": link.last_login,
"idp_access_token_expires_at": link.idp_access_token_expires_at,
"idp_refresh_token_updated_at": link.idp_refresh_token_updated_at,
}
# Fetch IDP details for display
idp = idp_crud.get_identity_provider(link.idp_id, db)
if idp:
link_dict["idp_name"] = idp.name
link_dict["idp_slug"] = idp.slug
link_dict["idp_icon"] = idp.icon
link_dict["idp_provider_type"] = idp.provider_type
enriched_links.append(link_dict)
return enriched_links
@router.delete(
"/idp/{idp_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_my_identity_provider(
idp_id: int,
token_user_id: Annotated[
int,
Depends(session_security.get_sub_from_access_token),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""
Delete (unlink) an identity provider from the authenticated user's account.
This endpoint allows users to remove the association between their account and
a specific identity provider. It includes critical safety checks to prevent
account lockout by ensuring users maintain at least one authentication method
(either a password or another IdP link).
Args:
idp_id (int): The ID of the identity provider to unlink.
token_user_id (int): The authenticated user's ID extracted from the access token.
db (Session): Database session dependency.
Returns:
None: Returns 204 No Content on successful deletion.
Raises:
HTTPException (404): If the identity provider doesn't exist or is not linked
to the user's account.
HTTPException (400): If attempting to unlink the last authentication method
without having a password set (prevents account lockout).
HTTPException (500): If the deletion operation fails at the database level.
Notes:
- Prevents account lockout by ensuring users have at least one authentication
method (password or remaining IdP link).
- Logs the unlinking action for audit purposes.
- Uses token-based authentication to ensure users can only unlink their own IdPs.
"""
# Validate IDP exists
idp = idp_crud.get_identity_provider(idp_id, db)
if idp is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity provider with id {idp_id} not found",
)
# Check if link exists for this user
link = user_idp_crud.get_user_idp_link(token_user_id, idp_id, db)
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity provider {idp.name} is not linked to your account",
)
# CRITICAL: Prevent account lockout
# Get user details to check if they have a password
user = users_crud.get_user_by_id(token_user_id, db)
# Count remaining IdP links after deletion
all_idp_links = user_idp_crud.get_user_idp_links(token_user_id, db)
remaining_idp_count = len(all_idp_links) - 1
# User must have either:
# - A password set, OR
# - At least one remaining IdP link
has_password = user.password is not None and user.password != ""
if not has_password and remaining_idp_count == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot unlink last authentication method. Please set a password first.",
)
# Proceed with deletion
success = user_idp_crud.delete_user_idp_link(token_user_id, idp_id, db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to unlink identity provider",
)
# Audit logging
core_logger.print_to_log(
f"User {token_user_id} unlinked IdP: idp_id={idp_id} ({idp.name})"
)
# Return 204 No Content (successful deletion)
return None
@router.get(
"/idp/{idp_id}/link",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
async def link_identity_provider(
idp_id: int,
request: Request,
token_user_id: Annotated[
int,
Depends(session_security.get_sub_from_access_token),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""
Initiate linking an identity provider to the authenticated user's account.
This endpoint starts the OAuth flow to link an external identity provider (IdP)
to the currently authenticated user. The user will be redirected to the IdP's
authorization page to complete the linking process.
Args:
idp_id (int): The ID of the identity provider to link.
request (Request): The FastAPI request object containing request context.
token_user_id (int): The authenticated user's ID extracted from the access token.
db (Session): The database session for performing CRUD operations.
Returns:
RedirectResponse: A redirect to the identity provider's authorization URL
with HTTP 307 status code.
Raises:
HTTPException:
- 404 NOT_FOUND: If the identity provider doesn't exist or is disabled.
- 409 CONFLICT: If the identity provider is already linked to the user's account.
- 500 INTERNAL_SERVER_ERROR: If an unexpected error occurs during the linking process.
Notes:
- The function validates that the IdP exists and is enabled before proceeding.
- Checks for existing links to prevent duplicate associations.
- Logs the linking initiation for audit purposes.
- Any errors during the OAuth flow initiation are logged and re-raised as HTTP exceptions.
"""
# Validate IDP exists and is enabled
idp = idp_crud.get_identity_provider(idp_id, db)
if not idp or not idp.enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Identity provider not found or disabled",
)
# Check if already linked
existing_link = user_idp_crud.get_user_idp_link(token_user_id, idp_id, db)
if existing_link:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Identity provider {idp.name} is already linked to your account",
)
# Initiate OAuth flow in "link mode"
try:
authorization_url = await idp_service.idp_service.initiate_link(
idp, request, token_user_id, db
)
# Audit logging
core_logger.print_to_log(
f"User {token_user_id} initiated IdP link: idp_id={idp_id} ({idp.name})"
)
return RedirectResponse(
url=authorization_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
except HTTPException:
raise
except Exception as err:
core_logger.print_to_log(
f"Error initiating IdP link for user {token_user_id}, idp_id={idp_id}: {err}",
"error",
exc=err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to initiate identity provider linking",
) from err

View File

@@ -28,6 +28,8 @@ USERS_ADMIN_SCOPE: Final[tuple[str, ...]] = (
)
GEARS_SCOPE: Final[tuple[str, ...]] = ("gears:read", "gears:write")
ACTIVITIES_SCOPE: Final[tuple[str, ...]] = ("activities:read", "activities:write")
IDENTITY_PROVIDERS_REGULAR_SCOPE: Final[tuple[str, ...]] = ("identity_providers:read",)
IDENTITY_PROVIDERS_ADMIN_SCOPE: Final[tuple[str, ...]] = ("identity_providers:write",)
HEALTH_SCOPE: Final[tuple[str, ...]] = (
"health:read",
"health:write",
@@ -57,6 +59,8 @@ SCOPE_DICT: Final[Mapping[str, str]] = MappingProxyType(
"health_targets:write": "Write privileges over health targets data",
"server_settings:read": "Read privileges over server settings",
"server_settings:write": "Write privileges over server settings",
"idp:read": "Read privileges over identity providers",
"idp:write": "Write privileges over identity providers",
}
)
@@ -66,7 +70,11 @@ REGULAR_ACCESS_SCOPE: Final[tuple[str, ...]] = (
+ ACTIVITIES_SCOPE
+ HEALTH_SCOPE
+ SERVER_SETTINGS_REGULAR_SCOPE
+ IDENTITY_PROVIDERS_REGULAR_SCOPE
)
ADMIN_ACCESS_SCOPE: Final[tuple[str, ...]] = (
REGULAR_ACCESS_SCOPE + USERS_ADMIN_SCOPE + SERVER_SETTINGS_ADMIN_SCOPE
REGULAR_ACCESS_SCOPE
+ USERS_ADMIN_SCOPE
+ IDENTITY_PROVIDERS_ADMIN_SCOPE
+ SERVER_SETTINGS_ADMIN_SCOPE
)

View File

@@ -37,7 +37,7 @@ To deploy Endurain, a Docker image is available, and a comprehensive example can
## Developer's Note
As a non-professional developer, my journey with Endurain involved learning and implementing new technologies and concepts, with invaluable assistance from ChatGPT. The primary motivation behind this project was to gain hands-on experience and expand my understanding of modern development practices. Second motivation is that I'm an amateur triathlete and I want to keep track of my gear and gear components usage.
As a non-professional developer, my journey with Endurain involved learning and implementing new technologies and concepts, with invaluable assistance from GitHub Copilot and ChatGPT. The primary motivation behind this project was to gain hands-on experience and expand my understanding of modern development practices. Second motivation is that I'm an amateur triathlete and I want to keep track of my gear and gear components usage.
If you have any recommendations or insights on improving any aspect of Endurain, whether related to technology choices, user experience, or any other relevant area, I would greatly appreciate your input. The goal is to create a reliable and user-friendly fitness tracking solution that caters to the needs of individuals who prefer self-hosted applications. Your constructive feedback will undoubtedly contribute to the refinement of Endurain.
@@ -104,4 +104,13 @@ Currently supported in:
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=joaovitoriasilva/endurain&type=Date)](https://star-history.com/#joaovitoriasilva/endurain&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=joaovitoriasilva/endurain&type=Date)](https://star-history.com/#joaovitoriasilva/endurain&Date)
**Trademark Notice:**
Endurain™ is a trademark of João Vitória Silva.
An application for registration with the European Union Intellectual Property Office (EUIPO) is currently pending.
You are welcome to self-host Endurain — including for personal, educational, research, or community (non-commercial) use — using the name and logo.
Commercial use of the Endurain name or Logos (such as offering paid hosting, products, or services) is **not permitted without prior written permission**.
See [`TRADEMARK.md`](https://github.com/joaovitoriasilva/endurain/blob/master/TRADEMARK.md) for full details.

View File

@@ -10,7 +10,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" :id="`${modalId}Title`">{{ title }}</h5>
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
@@ -78,23 +78,25 @@
/**
* ModalComponentDateRangeInput
*
* Modal component for selecting a date range with start and end dates.
* Defaults to last 7 days when mounted.
* Reusable modal component for selecting a date range with start and end dates.
* Defaults to last 7 days when mounted. Follows the same structure and patterns as ModalComponent.vue.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted, type PropType } from 'vue'
// Internationalization
import { useI18n } from 'vue-i18n'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// ============================================================================
// Types
// Section 2: Props & Emits
// ============================================================================
interface DateRange {
@@ -102,10 +104,6 @@ interface DateRange {
endDate: string
}
// ============================================================================
// Props & Emits
// ============================================================================
const props = defineProps({
modalId: {
type: String,
@@ -131,14 +129,13 @@ const emit = defineEmits<{
}>()
// ============================================================================
// Composables & State
// Section 3: Composables & Stores
// ============================================================================
const { t } = useI18n()
const { initializeModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Reactive State
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
@@ -146,7 +143,7 @@ const startDate = ref('')
const endDate = ref('')
// ============================================================================
// Main Logic
// Section 7: Validation Logic
// ============================================================================
/**
@@ -162,6 +159,10 @@ const setDefaultDates = (): void => {
endDate.value = today.toISOString().split('T')[0] || ''
}
// ============================================================================
// Section 8: Main Logic
// ============================================================================
/**
* Emit selected date range to parent component
*/
@@ -173,7 +174,7 @@ const emitDates = (): void => {
}
// ============================================================================
// Lifecycle Hooks
// Section 9: Lifecycle Hooks
// ============================================================================
/**

View File

@@ -1,15 +1,16 @@
<template>
<div
ref="modalRef"
class="modal fade"
:id="`${modalId}`"
:id="modalId"
tabindex="-1"
:aria-labelledby="`${modalId}`"
:aria-labelledby="`${modalId}Title`"
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>
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
@@ -20,13 +21,15 @@
<div class="modal-body">
<label :for="`${modalId}Email`" class="form-label">{{ emailFieldLabel }}</label>
<input
:id="`${modalId}Email`"
v-model="emailToEmit"
type="email"
class="form-control"
:class="{ 'is-invalid': !isEmailValid }"
:name="`${modalId}Email`"
:id="`${modalId}Email`"
v-model="emailToEmit"
:placeholder="emailFieldLabel"
:aria-label="emailFieldLabel"
aria-describedby="validationEmailFeedback"
required
/>
<div id="validationEmailFeedback" class="invalid-feedback" v-if="!isEmailValid">
@@ -35,12 +38,17 @@
<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">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<button
type="button"
@click="submitAction()"
@click="submitAction"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
@@ -49,6 +57,7 @@
'btn-primary': actionButtonType === 'primary'
}"
:disabled="isLoading"
:aria-label="actionButtonText"
>
<span
v-if="isLoading"
@@ -66,92 +75,89 @@
<script setup lang="ts">
/**
* ModalComponentEmailInput Component
* ModalComponentEmailInput
*
* Reusable modal component for email input with RFC 5322 compliant validation.
* Follows the same structure and patterns as ModalComponent.vue.
*
* A reusable Bootstrap modal component for email input operations.
* Features:
* - RFC 5322 compliant email validation
* - Real-time validation feedback
* - Real-time email validation feedback
* - Loading state support
* - Customizable button types (success, danger, warning, primary)
* - Input sanitization
* - Customizable button types (success, danger, warning, primary)
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, computed, type Ref, type ComputedRef } from 'vue'
import { ref, computed, onMounted, onUnmounted, type PropType } from 'vue'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// Utils
import { isValidEmail, sanitizeInput } from '@/utils/validationUtils'
// ============================================================================
// Types
// Section 2: Props & Emits
// ============================================================================
/**
* Component props interface
*/
interface Props {
/** Unique identifier for the modal element */
modalId: string
/** Modal header title */
title: string
/** Label for the email input field */
emailFieldLabel: string
/** Optional help text displayed below input */
emailHelpText?: string
/** Default value for email input */
emailDefaultValue?: string
/** Button style type */
actionButtonType: ActionButtonType
/** Text displayed on action button */
actionButtonText: string
/** Loading state indicator */
isLoading?: boolean
}
const props = withDefaults(
defineProps<{
/** Unique identifier for the modal element */
modalId: string
/** Modal header title */
title: string
/** Label for the email input field */
emailFieldLabel: string
/** Optional help text displayed below input */
emailHelpText?: string
/** Default value for email input */
emailDefaultValue?: string
/** Button style type */
actionButtonType: ActionButtonType
/** Text displayed on action button */
actionButtonText: string
/** Loading state indicator */
isLoading?: boolean
}>(),
{
emailHelpText: '',
emailDefaultValue: '',
isLoading: false
}
)
/**
* Component emits interface
*/
interface Emits {
(e: 'emailToEmitAction', email: string): void
}
const emit = defineEmits<{
emailToEmitAction: [email: string]
}>()
// ============================================================================
// Props & Emits
// Section 3: Composables & Stores
// ============================================================================
const props = withDefaults(defineProps<Props>(), {
emailHelpText: '',
emailDefaultValue: '',
isLoading: false
})
const emit = defineEmits<Emits>()
const { initializeModal, disposeModal } = useBootstrapModal()
// ============================================================================
// State
// Section 4: Reactive State
// ============================================================================
/**
* Email input value
* Initialized with default value from props
*/
const emailToEmit: Ref<string> = ref(props.emailDefaultValue)
const modalRef = ref<HTMLDivElement | null>(null)
const emailToEmit = ref(props.emailDefaultValue)
// ============================================================================
// Computed Properties
// Section 5: Computed Properties
// ============================================================================
/**
* Validate email format using RFC 5322 compliant validation
* Returns true if email is valid or empty (to avoid showing error on load)
*
* @returns {boolean} Email validation state
*/
const isEmailValid: ComputedRef<boolean> = computed(() => {
const isEmailValid = computed(() => {
// Don't show validation error for empty input
if (!emailToEmit.value) return true
@@ -159,7 +165,7 @@ const isEmailValid: ComputedRef<boolean> = computed(() => {
})
// ============================================================================
// Actions
// Section 8: Main Logic
// ============================================================================
/**
@@ -178,4 +184,22 @@ const submitAction = (): void => {
emit('emailToEmitAction', sanitizedEmail)
}
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
})
/**
* Clean up modal on unmount
*/
onUnmounted(() => {
disposeModal()
})
</script>

View File

@@ -0,0 +1,256 @@
<template>
<div
ref="modalRef"
class="modal fade"
:id="modalId"
tabindex="-1"
:aria-labelledby="`${modalId}Title`"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div v-if="qrCodeData">
<p>{{ instructions }}</p>
<div class="text-center mb-3">
<img :src="qrCodeData" alt="QR Code" class="img-fluid" style="max-width: 200px" />
</div>
<p>
<strong>{{ secretLabel }}:</strong>
<code class="ms-1">{{ mfaSecret }}</code>
</p>
<form @submit.prevent="handleSubmit">
<label :for="`${modalId}VerificationCode`" class="form-label">
<b>* {{ verificationCodeLabel }}</b>
</label>
<input
:id="`${modalId}VerificationCode`"
v-model="verificationCode"
type="text"
class="form-control"
:name="`${modalId}VerificationCode`"
:placeholder="verificationCodePlaceholder"
:aria-label="verificationCodeLabel"
required
/>
<p class="mt-2">* {{ requiredFieldText }}</p>
<div class="d-flex justify-content-end">
<button
type="button"
class="btn btn-secondary me-2"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ cancelButtonText }}
</button>
<button
type="submit"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
'btn-danger': actionButtonType === 'danger',
'btn-warning': actionButtonType === 'warning',
'btn-primary': actionButtonType === 'primary'
}"
:disabled="!verificationCode || isLoading"
:aria-label="actionButtonText"
>
<span
v-if="isLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ actionButtonText }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* ModalComponentMFASetup
*
* Reusable modal component for MFA (Multi-Factor Authentication) setup.
* Displays QR code, secret key, and verification code input.
* Follows the same structure and patterns as ModalComponent.vue.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted, type PropType } from 'vue'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// ============================================================================
// Section 2: Props & Emits
// ============================================================================
const props = defineProps({
modalId: {
type: String,
required: true
},
title: {
type: String,
required: true
},
instructions: {
type: String,
required: true
},
qrCodeData: {
type: String,
required: true
},
mfaSecret: {
type: String,
required: true
},
secretLabel: {
type: String,
required: true
},
verificationCodeLabel: {
type: String,
required: true
},
verificationCodePlaceholder: {
type: String,
required: true
},
requiredFieldText: {
type: String,
required: true
},
cancelButtonText: {
type: String,
required: true
},
actionButtonType: {
type: String as PropType<ActionButtonType>,
required: true,
validator: (value: string) => ['success', 'danger', 'warning', 'primary'].includes(value)
},
actionButtonText: {
type: String,
required: true
},
isLoading: {
type: Boolean,
default: false
}
})
const emit = defineEmits<{
submitAction: [verificationCode: string]
}>()
// ============================================================================
// Section 3: Composables & Stores
// ============================================================================
const { modalInstance, initializeModal, showModal, hideModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
const verificationCode = ref('')
// ============================================================================
// Section 6: UI Interaction Handlers
// ============================================================================
/**
* Reset verification code when modal is hidden
*/
const handleModalHidden = (): void => {
verificationCode.value = ''
}
// ============================================================================
// Section 8: Main Logic
// ============================================================================
/**
* Handle form submission and emit verification code
*/
const handleSubmit = (): void => {
if (verificationCode.value) {
emit('submitAction', verificationCode.value)
}
}
/**
* Show the modal (exposed for parent component)
*/
const show = (): void => {
showModal()
}
/**
* Hide the modal (exposed for parent component)
*/
const hide = (): void => {
hideModal()
verificationCode.value = ''
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
// Listen for modal hidden event to reset form
if (modalRef.value) {
modalRef.value.addEventListener('hidden.bs.modal', handleModalHidden)
}
})
/**
* Clean up modal on unmount
*/
onUnmounted(() => {
if (modalRef.value) {
modalRef.value.removeEventListener('hidden.bs.modal', handleModalHidden)
}
disposeModal()
})
// ============================================================================
// Section 10: Component Definition (Expose methods)
// ============================================================================
defineExpose({
show,
hide
})
</script>

View File

@@ -1,15 +1,16 @@
<template>
<div
ref="modalRef"
class="modal fade"
:id="`${modalId}`"
:id="modalId"
tabindex="-1"
:aria-labelledby="`${modalId}`"
:aria-labelledby="`${modalId}Title`"
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>
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
@@ -18,56 +19,98 @@
></button>
</div>
<div class="modal-body">
<!-- number field -->
<label for="numberToEmit"
><b>* {{ numberFieldLabel }}</b></label
>
<input
class="form-control"
type="number"
name="numberToEmit"
:placeholder="`${numberFieldLabel}`"
v-model="numberToEmit"
required
/>
<!-- string field -->
<label for="stringToEmit"
><b>* {{ stringFieldLabel }}</b></label
>
<input
class="form-control"
type="text"
name="stringToEmit"
:placeholder="`${stringFieldLabel}`"
v-model="stringToEmit"
required
/>
<!-- Number field -->
<div class="mb-3">
<label :for="`${modalId}NumberInput`" class="form-label">
<b>* {{ numberFieldLabel }}</b>
</label>
<input
:id="`${modalId}NumberInput`"
v-model="numberToEmit"
class="form-control"
type="number"
:name="`${modalId}NumberInput`"
:placeholder="numberFieldLabel"
:aria-label="numberFieldLabel"
required
/>
</div>
<!-- String field -->
<div>
<label :for="`${modalId}StringInput`" class="form-label">
<b>* {{ stringFieldLabel }}</b>
</label>
<input
:id="`${modalId}StringInput`"
v-model="stringToEmit"
class="form-control"
type="text"
:name="`${modalId}StringInput`"
:placeholder="stringFieldLabel"
:aria-label="stringFieldLabel"
required
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<a
<button
type="button"
@click="submitAction()"
@click="submitAction"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
'btn-danger': actionButtonType === 'danger',
'btn-warning': actionButtonType === 'warning',
'btn-primary': actionButtonType === 'loading'
'btn-primary': actionButtonType === 'primary'
}"
data-bs-dismiss="modal"
>{{ actionButtonText }}</a
:aria-label="actionButtonText"
>
{{ actionButtonText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
<script setup lang="ts">
/**
* ModalComponentNumberAndStringInput
*
* Reusable modal component for combined numeric and text input with configurable action button types.
* Follows the same structure and patterns as ModalComponent.vue.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted, type PropType } from 'vue'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// ============================================================================
// Section 2: Props & Emits
// ============================================================================
interface FieldsEmitPayload {
numberToEmit: number
stringToEmit: string
}
const props = defineProps({
modalId: {
@@ -95,8 +138,9 @@ const props = defineProps({
default: ''
},
actionButtonType: {
type: String,
required: true
type: String as PropType<ActionButtonType>,
required: true,
validator: (value: string) => ['success', 'danger', 'warning', 'primary'].includes(value)
},
actionButtonText: {
type: String,
@@ -104,15 +148,53 @@ const props = defineProps({
}
})
const emit = defineEmits(['fieldsToEmitAction'])
const emit = defineEmits<{
fieldsToEmitAction: [payload: FieldsEmitPayload]
}>()
// ============================================================================
// Section 3: Composables & Stores
// ============================================================================
const { initializeModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
const numberToEmit = ref(props.numberDefaultValue)
const stringToEmit = ref(props.stringDefaultValue)
function submitAction() {
// ============================================================================
// Section 8: Main Logic
// ============================================================================
/**
* Handle submit action and emit both field values
*/
const submitAction = (): void => {
emit('fieldsToEmitAction', {
numberToEmit: numberToEmit.value,
stringToEmit: stringToEmit.value
})
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
})
/**
* Clean up modal on unmount
*/
onUnmounted(() => {
disposeModal()
})
</script>

View File

@@ -1,15 +1,16 @@
<template>
<div
ref="modalRef"
class="modal fade"
:id="`${modalId}`"
:id="modalId"
tabindex="-1"
:aria-labelledby="`${modalId}`"
:aria-labelledby="`${modalId}Title`"
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>
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
@@ -18,44 +19,75 @@
></button>
</div>
<div class="modal-body">
<!-- number field -->
<label for="numberToEmit"
><b>* {{ numberFieldLabel }}</b></label
>
<label :for="`${modalId}NumberInput`" class="form-label">
<b>* {{ numberFieldLabel }}</b>
</label>
<input
:id="`${modalId}NumberInput`"
v-model="numberToEmit"
class="form-control"
type="number"
name="numberToEmit"
:placeholder="`${numberFieldLabel}`"
v-model="numberToEmit"
:name="`${modalId}NumberInput`"
:placeholder="numberFieldLabel"
:aria-label="numberFieldLabel"
required
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<a
<button
type="button"
@click="submitAction()"
@click="submitAction"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
'btn-danger': actionButtonType === 'danger',
'btn-warning': actionButtonType === 'warning',
'btn-primary': actionButtonType === 'loading'
'btn-primary': actionButtonType === 'primary'
}"
data-bs-dismiss="modal"
>{{ actionButtonText }}</a
:aria-label="actionButtonText"
:disabled="!isValid"
>
{{ actionButtonText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
<script setup lang="ts">
/**
* ModalComponentNumberInput
*
* Reusable modal component for numeric input with configurable action button types.
* Follows the same structure and patterns as ModalComponent.vue.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted, computed, type PropType } from 'vue'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// ============================================================================
// Section 2: Props & Emits
// ============================================================================
const props = defineProps({
modalId: {
@@ -71,12 +103,13 @@ const props = defineProps({
required: true
},
numberDefaultValue: {
type: Number,
default: 7
type: [Number, null] as PropType<number | null>,
default: null
},
actionButtonType: {
type: String,
required: true
type: String as PropType<ActionButtonType>,
required: true,
validator: (value: string) => ['success', 'danger', 'warning', 'primary'].includes(value)
},
actionButtonText: {
type: String,
@@ -84,11 +117,60 @@ const props = defineProps({
}
})
const emit = defineEmits(['numberToEmitAction'])
const emit = defineEmits<{
numberToEmitAction: [value: number]
}>()
const numberToEmit = ref(props.numberDefaultValue)
// ============================================================================
// Section 3: Composables & Stores
// ============================================================================
function submitAction() {
emit('numberToEmitAction', numberToEmit.value)
const { initializeModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
const numberToEmit = ref<number | null>(props.numberDefaultValue)
// ============================================================================
// Section 7: Validation Logic
// ============================================================================
/**
* Check if the input has a valid numeric value
*/
const isValid = computed(() => numberToEmit.value !== null && numberToEmit.value !== undefined)
// ============================================================================
// Section 8: Main Logic
// ============================================================================
/**
* Handle submit action and emit the numeric value
*/
const submitAction = (): void => {
if (isValid.value && numberToEmit.value !== null && numberToEmit.value !== undefined) {
emit('numberToEmitAction', numberToEmit.value)
}
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
})
/**
* Clean up modal on unmount
*/
onUnmounted(() => {
disposeModal()
})
</script>

View File

@@ -1,15 +1,16 @@
<template>
<div
ref="modalRef"
class="modal fade"
:id="`${modalId}`"
:id="modalId"
tabindex="-1"
:aria-labelledby="`${modalId}`"
:aria-labelledby="`${modalId}Title`"
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>
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
@@ -18,41 +19,81 @@
></button>
</div>
<div class="modal-body">
<!-- number field -->
<label for="selectToEmit"
><b>* {{ selectFieldLabel }}</b></label
<label :for="`${modalId}Select`" class="form-label">
<b>* {{ selectFieldLabel }}</b>
</label>
<select
:id="`${modalId}Select`"
v-model="optionToEmit"
class="form-select"
:name="`${modalId}Select`"
:aria-label="selectFieldLabel"
required
>
<select class="form-select" name="selectToEmit" v-model="optionToEmit" required>
<option v-for="select in selectOptions" :key="select.id" :value="select.id">
{{ select.name }}
</option>
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<a
<button
type="button"
@click="submitAction()"
@click="submitAction"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
'btn-danger': actionButtonType === 'danger',
'btn-warning': actionButtonType === 'warning',
'btn-primary': actionButtonType === 'loading'
'btn-primary': actionButtonType === 'primary'
}"
data-bs-dismiss="modal"
>{{ actionButtonText }}</a
:aria-label="actionButtonText"
>
{{ actionButtonText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
<script setup lang="ts">
/**
* ModalComponentSelectInput
*
* Reusable modal component for dropdown/select input with configurable action button types.
* Follows the same structure and patterns as ModalComponent.vue.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted, type PropType } from 'vue'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// ============================================================================
// Section 2: Props & Emits
// ============================================================================
interface SelectOption {
id: number
name: string
}
const props = defineProps({
modalId: {
@@ -68,7 +109,7 @@ const props = defineProps({
required: true
},
selectOptions: {
type: Array,
type: Array as PropType<SelectOption[]>,
required: true
},
selectCurrentOption: {
@@ -76,8 +117,9 @@ const props = defineProps({
required: true
},
actionButtonType: {
type: String,
required: true
type: String as PropType<ActionButtonType>,
required: true,
validator: (value: string) => ['success', 'danger', 'warning', 'primary'].includes(value)
},
actionButtonText: {
type: String,
@@ -85,11 +127,49 @@ const props = defineProps({
}
})
const emit = defineEmits(['optionToEmitAction'])
const emit = defineEmits<{
optionToEmitAction: [value: number]
}>()
// ============================================================================
// Section 3: Composables & Stores
// ============================================================================
const { initializeModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
const optionToEmit = ref(props.selectCurrentOption)
function submitAction() {
// ============================================================================
// Section 8: Main Logic
// ============================================================================
/**
* Handle submit action and emit the selected option value
*/
const submitAction = (): void => {
emit('optionToEmitAction', optionToEmit.value)
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
})
/**
* Clean up modal on unmount
*/
onUnmounted(() => {
disposeModal()
})
</script>

View File

@@ -1,15 +1,16 @@
<template>
<div
ref="modalRef"
class="modal fade"
:id="`${modalId}`"
:id="modalId"
tabindex="-1"
:aria-labelledby="`${modalId}`"
:aria-labelledby="`${modalId}Title`"
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>
<h1 class="modal-title fs-5" :id="`${modalId}Title`">{{ title }}</h1>
<button
type="button"
class="btn-close"
@@ -18,43 +19,76 @@
></button>
</div>
<div class="modal-body">
<!-- file field -->
<label for="fileToEmit"
><b>* {{ fileFieldLabel }}</b></label
>
<label :for="`${modalId}FileInput`" class="form-label">
<b>* {{ fileFieldLabel }}</b>
</label>
<input
:id="`${modalId}FileInput`"
ref="fileInputRef"
class="form-control"
type="file"
name="fileToEmit"
:placeholder="`${fileFieldLabel}`"
:name="`${modalId}FileInput`"
:accept="filesAccepted"
:aria-label="fileFieldLabel"
@change="handleFileChange"
required
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<a
<button
type="button"
@click="submitAction()"
@click="submitAction"
class="btn"
:class="{
'btn-success': actionButtonType === 'success',
'btn-danger': actionButtonType === 'danger',
'btn-warning': actionButtonType === 'warning',
'btn-primary': actionButtonType === 'loading'
'btn-primary': actionButtonType === 'primary'
}"
data-bs-dismiss="modal"
>{{ actionButtonText }}</a
:aria-label="actionButtonText"
>
{{ actionButtonText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
<script setup lang="ts">
/**
* ModalComponentUploadFile
*
* Reusable modal component for file upload with configurable accepted file types.
* Follows the same structure and patterns as ModalComponent.vue.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted, type PropType } from 'vue'
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Types
import type { ActionButtonType } from '@/types'
// ============================================================================
// Section 2: Props & Emits
// ============================================================================
const props = defineProps({
modalId: {
type: String,
@@ -73,8 +107,9 @@ const props = defineProps({
required: true
},
actionButtonType: {
type: String,
required: true
type: String as PropType<ActionButtonType>,
required: true,
validator: (value: string) => ['success', 'danger', 'warning', 'primary'].includes(value)
},
actionButtonText: {
type: String,
@@ -82,14 +117,74 @@ const props = defineProps({
}
})
const emit = defineEmits(['fileToEmitAction'])
const emit = defineEmits<{
fileToEmitAction: [file: File]
}>()
function submitAction() {
const fileInput = document.querySelector(`#${props.modalId} input[name="fileToEmit"]`)
const file = fileInput?.files[0]
// ============================================================================
// Section 3: Composables & Stores
// ============================================================================
const { initializeModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
// ============================================================================
// Section 6: UI Interaction Handlers
// ============================================================================
/**
* Handle file input change event
*/
const handleFileChange = (event: Event): void => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
emit('fileToEmitAction', file)
fileInput.value = ''
selectedFile.value = file
}
}
// ============================================================================
// Section 8: Main Logic
// ============================================================================
/**
* Handle submit action and emit the selected file
* Clears the file input after emission
*/
const submitAction = (): void => {
if (selectedFile.value) {
emit('fileToEmitAction', selectedFile.value)
// Clear file input and selected file
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
selectedFile.value = null
}
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
})
/**
* Clean up modal on unmount
*/
onUnmounted(() => {
disposeModal()
})
</script>

View File

@@ -1,17 +1,16 @@
<template>
<!-- Modal Garmin Connect authentication -->
<div
ref="modalRef"
class="modal fade"
id="garminConnectAuthModal"
ref="garminConnectAuthModal"
tabindex="-1"
aria-labelledby="garminConnectAuthModal"
aria-labelledby="garminConnectAuthModalTitle"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="garminConnectAuthModal">
<h1 class="modal-title fs-5" id="garminConnectAuthModalTitle">
{{ $t('garminConnectLoginModalComponent.garminConnectAuthModalTitle') }}
</h1>
<button
@@ -23,96 +22,99 @@
</div>
<form @submit.prevent="submitConnectGarminConnect">
<div class="modal-body">
<!-- username fields -->
<label for="garminConnectUsername"
><b
>*
{{ $t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel') }}</b
></label
>
<input
class="form-control"
type="text"
name="garminConnectUsername"
:placeholder="
$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernamePlaceholder')
"
v-model="garminConnectUsername"
required
/>
<!-- password fields -->
<label for="garminConnectPassword"
><b
>*
{{ $t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel') }}</b
></label
>
<input
class="form-control"
type="password"
name="garminConnectPassword"
:placeholder="
$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordPlaceholder')
"
v-model="garminConnectPassword"
required
/>
<!-- Username field -->
<div class="mb-3">
<label for="garminConnectUsername" class="form-label">
<b>* {{ $t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel') }}</b>
</label>
<input
id="garminConnectUsername"
v-model="garminConnectUsername"
class="form-control"
type="text"
name="garminConnectUsername"
:placeholder="$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernamePlaceholder')"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel')"
required
/>
</div>
<!-- Password field -->
<div class="mb-3">
<label for="garminConnectPassword" class="form-label">
<b>* {{ $t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel') }}</b>
</label>
<input
id="garminConnectPassword"
v-model="garminConnectPassword"
class="form-control"
type="password"
name="garminConnectPassword"
:placeholder="$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordPlaceholder')"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel')"
required
/>
</div>
<!-- MFA code field -->
<div class="row g-3 align-items-end" v-if="mfaRequired">
<div v-if="mfaRequired" class="row g-3 align-items-end">
<div class="col">
<label for="garminConnectMfaCode"
><b
>*
{{
$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel')
}}</b
></label
>
<label for="garminConnectMfaCode" class="form-label">
<b>* {{ $t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel') }}</b>
</label>
<input
id="garminConnectMfaCode"
v-model="mfaCode"
class="form-control"
type="text"
name="garminConnectMfaCode"
:placeholder="
$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodePlaceholder')
"
v-model="mfaCode"
:placeholder="$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodePlaceholder')"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel')"
/>
</div>
<div class="col">
<a
href="#"
<button
type="button"
class="btn btn-success w-100"
:class="{ disabled: loadingLoginWithMfa }"
:disabled="loadingLoginWithMfa"
:aria-label="$t('garminConnectLoginModalComponent.buttonSubmitMfaCode')"
@click="submitMfaCode"
>
<span
class="spinner-border spinner-border-sm me-1"
aria-hidden="true"
v-if="loadingLoginWithMfa"
class="spinner-border spinner-border-sm me-1"
role="status"
aria-hidden="true"
></span>
<span role="status">{{
$t('garminConnectLoginModalComponent.buttonSubmitMfaCode')
}}</span>
</a>
{{ $t('garminConnectLoginModalComponent.buttonSubmitMfaCode') }}
</button>
</div>
</div>
<p>* {{ $t('generalItems.requiredField') }}</p>
<p class="mt-2">* {{ $t('generalItems.requiredField') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<button type="submit" class="btn btn-success" :disabled="loadingLogin">
<button
type="submit"
class="btn btn-success"
:disabled="loadingLogin"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalLoginButton')"
>
<span
class="spinner-border spinner-border-sm me-1"
aria-hidden="true"
v-if="loadingLogin"
class="spinner-border spinner-border-sm me-1"
role="status"
aria-hidden="true"
></span>
<span role="status">{{
$t('garminConnectLoginModalComponent.garminConnectAuthModalLoginButton')
}}</span>
{{ $t('garminConnectLoginModalComponent.garminConnectAuthModalLoginButton') }}
</button>
</div>
</form>
@@ -121,92 +123,173 @@
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
<script setup lang="ts">
/**
* GarminConnectLoginModalComponent
*
* Modal component for authenticating and linking a Garmin Connect account.
* Supports MFA authentication via WebSocket for 2FA verification.
* Follows the same structure and patterns as other modal components.
*
* @component
*/
// ============================================================================
// Section 1: Imports
// ============================================================================
// Vue composition API
import { ref, onMounted, onUnmounted } from 'vue'
// i18n
import { useI18n } from 'vue-i18n'
// Importing the stores
// Stores
import { useAuthStore } from '@/stores/authStore'
// Import Notivue push
import { push } from 'notivue'
// Importing the services
// Composables
import { useBootstrapModal } from '@/composables/useBootstrapModal'
// Services
import { garminConnect } from '@/services/garminConnectService'
// Importing the utils
import { removeActiveModal, resetBodyStylesIfNoActiveModals } from '@/utils/modalUtils'
// Importing the bootstrap modal
import Modal from 'bootstrap/js/src/modal'
// Notifications
import { push } from 'notivue'
// ============================================================================
// Section 3: Composables & Stores
// ============================================================================
const authStore = useAuthStore()
const { locale, t } = useI18n()
const { initializeModal, hideModal, disposeModal } = useBootstrapModal()
// ============================================================================
// Section 4: Reactive State
// ============================================================================
const modalRef = ref<HTMLDivElement | null>(null)
const garminConnectUsername = ref('')
const garminConnectPassword = ref('')
const mfaRequired = ref(false)
const mfaCode = ref('')
const garminConnectAuthModal = ref(null) // Ref for the modal element
const loadingLogin = ref(false)
const loadingLoginWithMfa = ref(false)
let modalInstance = null // Holds the modal instance
// ============================================================================
// Section 6: UI Interaction Handlers
// ============================================================================
// Set up websocket message handler
authStore.user_websocket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data && data.message === 'MFA_REQUIRED') {
mfaRequired.value = true
/**
* WebSocket message handler for MFA requirement
*/
const handleWebSocketMessage = (event: MessageEvent): void => {
try {
const data = JSON.parse(event.data)
if (data?.message === 'MFA_REQUIRED') {
mfaRequired.value = true
}
} catch (error) {
console.error('Error parsing WebSocket message:', error)
}
}
// Initialize the modal instance on mount
onMounted(() => {
if (garminConnectAuthModal.value) {
modalInstance = new Modal(garminConnectAuthModal.value)
}
})
// ============================================================================
// Section 8: Main Logic
// ============================================================================
async function submitConnectGarminConnect() {
/**
* Submit Garmin Connect credentials to link account
* Handles success, error states, and modal cleanup
*/
const submitConnectGarminConnect = async (): Promise<void> => {
loadingLogin.value = true
// Set the loading message
const notification = push.promise(
t('garminConnectLoginModalComponent.processingMessageLinkGarminConnect')
)
try {
const data = {
username: garminConnectUsername.value,
password: garminConnectPassword.value
}
await garminConnect.linkGarminConnect(data)
// Set the user object with the is_garminconnect_linked property set to 1.
const user = authStore.user
// Update user object with linked status
const user = { ...authStore.user } as any
user.is_garminconnect_linked = 1
authStore.setUser(user, locale)
authStore.setUser(user, authStore.session_id, locale)
// Show success message
notification.resolve(t('garminConnectLoginModalComponent.successMessageLinkGarminConnect'))
// Hide modal and reset form
hideModal()
resetForm()
} catch (error) {
// If there is an error, show the error alert.
notification.reject(
`${t('garminConnectLoginModalComponent.errorMessageUnableToLinkGarminConnect')} - ${error}`
)
} finally {
// Remove any remaining modal backdrops
removeActiveModal(modalInstance)
// Reset body overflow to restore scrolling
resetBodyStylesIfNoActiveModals()
// reset variables
mfaRequired.value = false
mfaCode.value = ''
loadingLogin.value = false
}
}
/**
* Submit MFA code for two-factor authentication
*/
const submitMfaCode = async (): Promise<void> => {
if (!mfaCode.value) return
loadingLoginWithMfa.value = true
try {
const data = {
mfa_code: mfaCode.value
}
await garminConnect.mfaGarminConnect(data)
} catch (error) {
push.error(`${t('garminConnectLoginModalComponent.errorMessageUnableToLinkGarminConnect')} - ${error}`)
loadingLoginWithMfa.value = false
}
}
async function submitMfaCode() {
const data = {
mfa_code: mfaCode.value
}
await garminConnect.mfaGarminConnect(data)
loadingLoginWithMfa.value = true
/**
* Reset form fields and state
*/
const resetForm = (): void => {
garminConnectUsername.value = ''
garminConnectPassword.value = ''
mfaRequired.value = false
mfaCode.value = ''
loadingLogin.value = false
loadingLoginWithMfa.value = false
}
// ============================================================================
// Section 9: Lifecycle Hooks
// ============================================================================
/**
* Initialize modal and WebSocket handler on mount
*/
onMounted(async () => {
await initializeModal(modalRef)
// Set up WebSocket message handler for MFA
const websocket = authStore.user_websocket as WebSocket | null
if (websocket) {
websocket.onmessage = handleWebSocketMessage
}
})
/**
* Clean up modal and WebSocket handler on unmount
*/
onUnmounted(() => {
// Remove WebSocket message handler
const websocket = authStore.user_websocket as WebSocket | null
if (websocket) {
websocket.onmessage = null
}
disposeModal()
})
</script>

View File

@@ -128,7 +128,8 @@
<button
type="button"
class="btn btn-danger"
@click="showDisableMFAModal"
data-bs-toggle="modal"
data-bs-target="#mfaDisableModal"
:disabled="mfaDisableLoading"
>
{{ $t('settingsSecurityZone.disableMFAButton') }}
@@ -137,132 +138,76 @@
</div>
<!-- MFA Setup Modal -->
<div
class="modal fade"
id="mfaSetupModal"
tabindex="-1"
aria-labelledby="mfaSetupModalLabel"
aria-hidden="true"
ref="mfaSetupModal"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mfaSetupModalLabel">
{{ $t('settingsSecurityZone.mfaSetupModalTitle') }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div v-if="qrCodeData">
<p>{{ $t('settingsSecurityZone.mfaSetupInstructions') }}</p>
<div class="text-center mb-3">
<img :src="qrCodeData" alt="QR Code" class="img-fluid" style="max-width: 200px" />
</div>
<p>
<strong>{{ $t('settingsSecurityZone.mfaSecretLabel') }}:</strong>
<code class="ms-1">{{ mfaSecret }}</code>
</p>
<form @submit.prevent="enableMFA">
<label for="mfaVerificationCode"
><b>* {{ $t('settingsSecurityZone.mfaVerificationCodeLabel') }}</b></label
>
<input
type="text"
class="form-control"
id="mfaVerificationCode"
v-model="mfaVerificationCode"
:placeholder="$t('settingsSecurityZone.mfaVerificationCodePlaceholder')"
required
/>
<p class="mt-2">* {{ $t('generalItems.requiredField') }}</p>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">
{{ $t('generalItems.cancel') }}
</button>
<button
type="submit"
class="btn btn-success"
:disabled="!mfaVerificationCode || mfaEnableLoading"
>
<span
v-if="mfaEnableLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t('settingsSecurityZone.enableMFAButton') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<ModalComponentMFASetup
ref="mfaSetupModalRef"
modalId="mfaSetupModal"
:title="t('settingsSecurityZone.mfaSetupModalTitle')"
:instructions="t('settingsSecurityZone.mfaSetupInstructions')"
:qrCodeData="qrCodeData"
:mfaSecret="mfaSecret"
:secretLabel="t('settingsSecurityZone.mfaSecretLabel')"
:verificationCodeLabel="t('settingsSecurityZone.mfaVerificationCodeLabel')"
:verificationCodePlaceholder="t('settingsSecurityZone.mfaVerificationCodePlaceholder')"
:requiredFieldText="t('generalItems.requiredField')"
:cancelButtonText="t('generalItems.cancel')"
actionButtonType="success"
:actionButtonText="t('settingsSecurityZone.enableMFAButton')"
:isLoading="mfaEnableLoading"
@submitAction="enableMFA"
/>
<!-- MFA Disable Modal -->
<div
class="modal fade"
id="mfaDisableModal"
tabindex="-1"
aria-labelledby="mfaDisableModalLabel"
aria-hidden="true"
ref="mfaDisableModal"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mfaDisableModalLabel">
{{ $t('settingsSecurityZone.mfaDisableModalTitle') }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<p>{{ $t('settingsSecurityZone.mfaDisableConfirmation') }}</p>
<form @submit.prevent="disableMFA">
<label for="mfaDisableCode"
><b>* {{ $t('settingsSecurityZone.mfaVerificationCodeLabel') }}</b></label
>
<input
type="text"
class="form-control"
id="mfaDisableCode"
v-model="mfaDisableCode"
:placeholder="$t('settingsSecurityZone.mfaVerificationCodePlaceholder')"
required
/>
<p class="mt-2">* {{ $t('generalItems.requiredField') }}</p>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">
{{ $t('generalItems.cancel') }}
</button>
<button
type="submit"
class="btn btn-danger"
:disabled="!mfaDisableCode || mfaDisableLoading"
>
<span
v-if="mfaDisableLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t('settingsSecurityZone.disableMFAButton') }}
</button>
</div>
</form>
</div>
<ModalComponentNumberInput
modalId="mfaDisableModal"
:title="t('settingsSecurityZone.mfaDisableModalTitle')"
:numberFieldLabel="t('settingsSecurityZone.mfaVerificationCodeLabel')"
:numberDefaultValue="null"
:actionButtonType="`danger`"
:actionButtonText="t('settingsSecurityZone.disableMFAButton')"
@numberToEmitAction="disableMFA"
/>
<hr />
<!-- Linked Accounts (Identity Providers) -->
<h4>{{ $t('settingsSecurityZone.subtitleLinkedAccounts') }}</h4>
<p>{{ $t('settingsSecurityZone.linkedAccountsDescription') }}</p>
<div v-if="isLoadingLinkedAccounts">
<LoadingComponent />
</div>
<div v-else>
<!-- Linked Accounts List -->
<ul class="list-group" v-if="linkedAccounts && linkedAccounts.length > 0">
<UserIdentityProviderListComponent
v-for="account in linkedAccounts"
:key="account.id"
:idp="account"
:userId="authStore.user.id"
actionIcon="unlink"
:showProviderType="false"
@idpDeleted="unlinkAccount"
/>
</ul>
<!-- Available Providers to Link -->
<div v-if="availableProviders && availableProviders.length > 0" class="mt-3">
<h5>{{ $t('settingsSecurityZone.availableProvidersLabel') }}</h5>
<div class="d-flex flex-wrap gap-2">
<button
v-for="provider in availableProviders"
:key="provider.id"
type="button"
class="btn btn-secondary"
@click="linkAccount(provider.id)"
:aria-label="`${provider.name}`"
>
<img
:src="getProviderCustomLogo(provider.icon)"
:alt="`${provider.name} logo`"
style="height: 20px; width: 20px; object-fit: contain"
/>
{{ provider.name }}
</button>
</div>
</div>
</div>
@@ -291,20 +236,29 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from 'bootstrap/js/src/modal'
import { useRoute } from 'vue-router'
// Importing the services
import { profile } from '@/services/profileService'
import { identityProviders } from '@/services/identityProvidersService'
// Import Notivue push
import { push } from 'notivue'
// Importing the components
import UsersPasswordRequirementsComponent from '@/components/Settings/SettingsUsersZone/UsersPasswordRequirementsComponent.vue'
import ModalComponentNumberInput from '@/components/Modals/ModalComponentNumberInput.vue'
import ModalComponentMFASetup from '@/components/Modals/ModalComponentMFASetup.vue'
import UserIdentityProviderListComponent from '@/components/Settings/SettingsUsersZone/UserIdentityProviderListComponent.vue'
// Importing validation utilities
import { isValidPassword, passwordsMatch } from '@/utils/validationUtils'
import LoadingComponent from '@/components/GeneralComponents/LoadingComponent.vue'
import NoItemsFoundComponents from '@/components/GeneralComponents/NoItemsFoundComponents.vue'
import UserSessionsListComponent from '@/components/Settings/SettingsUserSessionsZone/UserSessionsListComponent.vue'
// Importing stores
import { useAuthStore } from '@/stores/authStore'
import { PROVIDER_CUSTOM_LOGO_MAP } from '@/constants/ssoConstants'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const newPassword = ref('')
const newPasswordRepeat = ref('')
const isNewPasswordValid = computed(() => {
@@ -327,17 +281,17 @@ const mfaEnableLoading = ref(false)
const mfaDisableLoading = ref(false)
const qrCodeData = ref('')
const mfaSecret = ref('')
const mfaVerificationCode = ref('')
const mfaDisableCode = ref('')
const mfaSetupModal = ref(null)
const mfaDisableModal = ref(null)
let mfaSetupModalInstance = null
let mfaDisableModalInstance = null
const mfaSetupModalRef = ref(null)
const showNewPassword = ref(false)
const showNewPasswordRepeat = ref(false)
// Linked Accounts (Identity Providers) variables
const linkedAccounts = ref([])
const availableProviders = ref([])
const allProviders = ref([])
const isLoadingLinkedAccounts = ref(false)
// Toggle visibility for new password
const toggleNewPasswordVisibility = () => {
showNewPassword.value = !showNewPassword.value
@@ -406,7 +360,7 @@ async function setupMFA() {
const setupData = await profile.setupMFA()
qrCodeData.value = setupData.qr_code
mfaSecret.value = setupData.secret
mfaSetupModalInstance.show()
mfaSetupModalRef.value?.show()
} catch (error) {
push.error(`${t('settingsSecurityZone.errorSetupMFA')} - ${error}`)
} finally {
@@ -414,13 +368,12 @@ async function setupMFA() {
}
}
async function enableMFA() {
async function enableMFA(verificationCode) {
try {
mfaEnableLoading.value = true
await profile.enableMFA({ mfa_code: mfaVerificationCode.value })
await profile.enableMFA({ mfa_code: verificationCode })
mfaEnabled.value = true
mfaSetupModalInstance.hide()
mfaVerificationCode.value = ''
mfaSetupModalRef.value?.hide()
qrCodeData.value = ''
mfaSecret.value = ''
push.success(t('settingsSecurityZone.mfaEnabledSuccess'))
@@ -431,17 +384,13 @@ async function enableMFA() {
}
}
function showDisableMFAModal() {
mfaDisableModalInstance.show()
}
async function disableMFA(mfaCode) {
if (!mfaCode) return
async function disableMFA() {
try {
mfaDisableLoading.value = true
await profile.disableMFA({ mfa_code: mfaDisableCode.value })
await profile.disableMFA({ mfa_code: mfaCode.toString() })
mfaEnabled.value = false
mfaDisableModalInstance.hide()
mfaDisableCode.value = ''
push.success(t('settingsSecurityZone.mfaDisabledSuccess'))
} catch (error) {
push.error(`${t('settingsSecurityZone.errorDisableMFA')} - ${error}`)
@@ -450,14 +399,86 @@ async function disableMFA() {
}
}
const getProviderCustomLogo = (iconName) => {
if (!iconName) return null
const logoPath = PROVIDER_CUSTOM_LOGO_MAP[iconName.toLowerCase()]
return logoPath || null
}
// Linked Accounts Functions
async function loadLinkedAccounts() {
try {
isLoadingLinkedAccounts.value = true
// Fetch linked accounts and available providers in parallel
linkedAccounts.value = await profile.getMyIdentityProviders()
allProviders.value = await identityProviders.getAllProviders()
// Filter out already linked providers
const linkedProviderIds = new Set(linkedAccounts.value.map((account) => account.idp_id))
availableProviders.value = allProviders.value.filter(
(provider) => !linkedProviderIds.has(provider.id)
)
} catch (error) {
push.error(`${t('settingsSecurityZone.errorLoadingLinkedAccounts')} - ${error}`)
} finally {
isLoadingLinkedAccounts.value = false
}
}
async function unlinkAccount(idpId) {
if (!idpId) return
try {
await profile.unlinkIdentityProvider(idpId)
// Find the account being unlinked
const unlinkedAccount = linkedAccounts.value.find((account) => account.idp_id === idpId)
// Remove from linked accounts list
linkedAccounts.value = linkedAccounts.value.filter((account) => account.idp_id !== idpId)
// Add back to available providers
const unlinkedProvider = allProviders.value.find((p) => p.id === idpId)
if (unlinkedProvider) {
availableProviders.value.push(unlinkedProvider)
}
push.success(t('settingsSecurityZone.unlinkAccountSuccess'))
} catch (error) {
const errorMessage = error.message || error.toString()
// Check for specific error scenarios
if (errorMessage.includes('last authentication method') || errorMessage.includes('400')) {
push.error(t('settingsSecurityZone.unlinkAccountLastMethodError'))
} else {
push.error(`${t('settingsSecurityZone.unlinkAccountError')} - ${errorMessage}`)
}
}
}
function linkAccount(providerId) {
// This will redirect to the OAuth flow
profile.linkIdentityProvider(providerId)
}
// Check for OAuth link success/error in URL params
function checkOAuthLinkStatus() {
const idpLink = route.query.idp_link
const idpName = route.query.idp_name
if (idpLink === 'success' && idpName) {
push.success(t('settingsSecurityZone.linkAccountSuccess', { providerName: idpName }))
// Reload linked accounts to show the new one
loadLinkedAccounts()
} else if (idpLink === 'error') {
push.error(t('settingsSecurityZone.linkAccountError'))
}
}
onMounted(async () => {
// Initialize modal instances
if (mfaSetupModal.value) {
mfaSetupModalInstance = new Modal(mfaSetupModal.value)
}
if (mfaDisableModal.value) {
mfaDisableModalInstance = new Modal(mfaDisableModal.value)
}
// Check for OAuth callback status
checkOAuthLinkStatus()
// Fetch the user sessions
userSessions.value = await profile.getProfileSessions()
@@ -465,6 +486,9 @@ onMounted(async () => {
// Load MFA status
await loadMFAStatus()
// Load linked accounts
await loadLinkedAccounts()
// Set the isLoading to false
isLoading.value = false
})

View File

@@ -21,8 +21,10 @@
<!-- IDP Details -->
<div>
<div class="fw-bold">
{{ idp.idp_name }} -
<span class="fw-lighter">{{ formatProviderType(idp.idp_provider_type) }}</span>
{{ idp.idp_name }}
<span v-if="showProviderType" class="fw-lighter">
- {{ formatProviderType(idp.idp_provider_type) }}</span
>
</div>
<div class="text-muted small">
<span :aria-label="t('userIdentityProviderListComponent.linkedAtLabel')">
@@ -46,27 +48,25 @@
<!-- Actions -->
<div class="d-flex align-items-center">
<!-- Delete IDP Link Button -->
<!-- Delete/Unlink IDP Link Button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#deleteIdpModal${idp.id}`"
:aria-label="
t('userIdentityProviderListComponent.deleteButtonAriaLabel', { provider: idp.idp_name })
"
:data-bs-target="`#idpActionModal${idp.id}`"
:aria-label="actionButtonAriaLabel"
>
<font-awesome-icon :icon="['fas', 'fa-trash-can']" />
<font-awesome-icon :icon="actionIconName" />
</a>
<!-- Delete IDP Modal -->
<!-- Delete/Unlink IDP Modal -->
<ModalComponent
:modalId="`deleteIdpModal${idp.id}`"
:title="t('userIdentityProviderListComponent.modalDeleteTitle')"
:body="`${t('userIdentityProviderListComponent.modalDeleteBody1')} <b>${idp.idp_name}</b>${t('userIdentityProviderListComponent.modalDeleteBody2')}`"
:modalId="`idpActionModal${idp.id}`"
:title="modalTitle"
:body="modalBody"
:actionButtonType="`danger`"
:actionButtonText="t('userIdentityProviderListComponent.modalDeleteButton')"
:actionButtonText="modalButtonText"
@submitAction="submitDeleteIdp"
/>
</div>
@@ -78,24 +78,37 @@
/**
* @fileoverview UserIdentityProviderListComponent - Display and manage user external authentication provider links
*
* This component displays a single identity provider (IDP) link for a user in the admin panel.
* Shows IDP details (name, icon, linked date, last login) and provides delete functionality.
* Used within the Users List component's IDP tab.
* This component displays a single identity provider (IDP) link for a user.
* Shows IDP details (name, icon, linked date, last login) and provides delete/unlink functionality.
* Used in both admin panel (Users List IDP tab) and user settings (Linked Accounts section).
*
* Features:
* - Display IDP metadata (provider name, type, icon)
* - Show link creation date and last login timestamp
* - Delete IDP link with modal confirmation
* - Delete/unlink IDP link with modal confirmation
* - Context-aware: trash icon (admin) or unlink icon (self-service)
* - Optional provider type display
* - Accessibility: Full ARIA support and keyboard navigation
* - i18n: Multi-language support
*
* @component
* @example
* // Admin context (default)
* <UserIdentityProviderListComponent
* :idp="identityProvider"
* :userId="123"
* @idpDeleted="handleIdpDeleted"
* />
*
* @example
* // User settings context
* <UserIdentityProviderListComponent
* :idp="identityProvider"
* :userId="currentUser.id"
* actionIcon="unlink"
* :showProviderType="false"
* @idpDeleted="handleUnlinkAccount"
* />
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -104,12 +117,22 @@ import { PROVIDER_CUSTOM_LOGO_MAP } from '@/constants/ssoConstants'
import type { UserIdentityProviderEnriched } from '@/types'
import ModalComponent from '@/components/Modals/ModalComponent.vue'
const props = defineProps<{
/** The identity provider link object with enriched metadata */
idp: UserIdentityProviderEnriched
/** The user ID who owns this IDP link */
userId: number
}>()
const props = withDefaults(
defineProps<{
/** The identity provider link object with enriched metadata */
idp: UserIdentityProviderEnriched
/** The user ID who owns this IDP link */
userId: number
/** Action icon type: 'trash' for admin delete, 'unlink' for user self-service */
actionIcon?: 'trash' | 'unlink'
/** Whether to show the provider type (e.g., "OpenID Connect") */
showProviderType?: boolean
}>(),
{
actionIcon: 'trash',
showProviderType: true
}
)
const emit = defineEmits<{
/** Emitted when IDP link is successfully deleted */
@@ -158,6 +181,61 @@ const formatProviderType = computed(() => {
}
})
/**
* UI State & Computed Properties
*/
/**
* Action icon for delete/unlink button
* Returns FontAwesome icon array based on actionIcon prop
*/
const actionIconName = computed(() =>
props.actionIcon === 'unlink' ? ['fas', 'unlink'] : ['fas', 'fa-trash-can']
)
/**
* ARIA label for action button
* Context-aware label for accessibility
*/
const actionButtonAriaLabel = computed(() => {
const action = props.actionIcon === 'unlink' ? 'Unlink' : 'Delete'
return `${action} ${props.idp.idp_name}`
})
/**
* Modal title text
* Context-aware title based on action type
*/
const modalTitle = computed(() =>
props.actionIcon === 'unlink'
? t('settingsSecurityZone.unlinkModalTitle')
: t('userIdentityProviderListComponent.modalDeleteTitle')
)
/**
* Modal body text
* Context-aware confirmation message
*/
const modalBody = computed(() =>
props.actionIcon === 'unlink'
? t('settingsSecurityZone.unlinkModalConfirmation', { providerName: props.idp.idp_name })
: `${t('userIdentityProviderListComponent.modalDeleteBody1')} <b>${props.idp.idp_name}</b>${t('userIdentityProviderListComponent.modalDeleteBody2')}`
)
/**
* Modal button text
* Context-aware action button label
*/
const modalButtonText = computed(() =>
props.actionIcon === 'unlink'
? t('settingsSecurityZone.unlinkAccountButton')
: t('userIdentityProviderListComponent.modalDeleteButton')
)
/**
* Main Logic
*/
/**
* Handles IDP deletion modal submission
* Emits idpDeleted event to parent component for actual API call

View File

@@ -152,7 +152,7 @@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active"
class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sessions-tab-${user.id}`"
data-bs-toggle="tab"
:data-bs-target="`#sessions-${user.id}`"
@@ -166,7 +166,7 @@
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`idps-tab-${user.id}`"
data-bs-toggle="tab"
:data-bs-target="`#idps-${user.id}`"

View File

@@ -58,12 +58,37 @@ export function useBootstrapModal() {
try {
modalInstance.value = new Modal(element)
isInitialized.value = true
// Listen for Bootstrap's hidden event to clean up body styles
element.addEventListener('hidden.bs.modal', () => {
cleanupBodyStyles()
})
} catch (error) {
console.error('Failed to initialize Bootstrap modal:', error)
throw error
}
}
/**
* Clean up body styles and attributes left by Bootstrap
*/
const cleanupBodyStyles = (): void => {
// Check if any other modals are still open
const openModals = document.querySelectorAll('.modal.show')
if (openModals.length === 0) {
// Remove all Bootstrap modal-related classes and styles
document.body.classList.remove('modal-open')
document.body.style.overflow = ''
document.body.style.paddingRight = ''
document.body.removeAttribute('data-bs-overflow')
document.body.removeAttribute('data-bs-padding-right')
// Clean up any remaining backdrops
const backdrops = document.querySelectorAll('.modal-backdrop')
backdrops.forEach((backdrop) => backdrop.remove())
}
}
/**
* Show the modal
*
@@ -88,20 +113,7 @@ export function useBootstrapModal() {
return
}
modalInstance.value.hide()
// Clean up any remaining backdrops (Bootstrap sometimes leaves them)
setTimeout(() => {
const backdrops = document.querySelectorAll('.modal-backdrop')
backdrops.forEach((backdrop) => backdrop.remove())
// Reset body styles if no other modals are open
const openModals = document.querySelectorAll('.modal.show')
if (openModals.length === 0) {
document.body.classList.remove('modal-open')
document.body.style.overflow = ''
document.body.style.paddingRight = ''
}
}, 300) // Wait for Bootstrap's fade animation to complete
// Cleanup is handled by the 'hidden.bs.modal' event listener
}
/**

View File

@@ -27,5 +27,24 @@
"userChangePasswordSuccessMessage": "Password changed successfully",
"userChangePasswordErrorMessage": "Error changing password",
"successDeleteSession": "Session deleted successfully",
"errorDeleteSession": "Error deleting session"
"errorDeleteSession": "Error deleting session",
"subtitleLinkedAccounts": "Linked accounts",
"linkedAccountsDescription": "Manage external authentication providers (SSO) linked to your account. You can sign in using any of these providers.",
"linkedAccountsLastLogin": "Last login",
"linkedAccountsLinkedAt": "Linked",
"linkedAccountsNeverUsed": "Never used",
"unlinkAccountButton": "Unlink",
"availableProvidersLabel": "Available providers",
"unlinkModalTitle": "Unlink Account",
"unlinkModalConfirmation": "Are you sure you want to unlink your {providerName} account? You will no longer be able to sign in using this provider.",
"unlinkModalLastMethodWarning": "Warning: This is your only authentication method. Please set a password before unlinking.",
"unlinkAccountSuccess": "Account unlinked successfully",
"unlinkAccountError": "Error unlinking account",
"unlinkAccountLastMethodError": "Cannot unlink last authentication method. Please set a password first.",
"linkAccountSuccess": "{providerName} account linked successfully",
"linkAccountError": "Error linking account",
"linkAccountAlreadyLinked": "This {providerName} account is already linked",
"linkAccountInUse": "This {providerName} account is already linked to another user",
"errorLoadingLinkedAccounts": "Error loading linked accounts",
"refreshButton": "Refresh"
}

View File

@@ -8,7 +8,8 @@ import {
fetchGetRequest,
fetchPostRequest,
fetchPutRequest,
fetchDeleteRequest
fetchDeleteRequest,
API_URL
} from '@/utils/serviceUtils'
import { fetchPublicGetRequest } from '@/utils/servicePublicUtils'
@@ -83,6 +84,6 @@ export const identityProviders = {
* @param {string} slug - The provider slug
*/
initiateLogin(slug) {
window.location.href = `${window.env.ENDURAIN_HOST}/api/v1/public/idp/login/${slug}`
window.location.href = `${API_URL}public/idp/login/${slug}`
}
}

View File

@@ -3,7 +3,8 @@ import {
fetchPostRequest,
fetchPutRequest,
fetchDeleteRequest,
fetchPostFileRequest
fetchPostFileRequest,
API_URL
} from '@/utils/serviceUtils'
export const profile = {
@@ -58,5 +59,14 @@ export const profile = {
},
verifyMFA(data) {
return fetchPostRequest('profile/mfa/verify', data)
},
getMyIdentityProviders() {
return fetchGetRequest('profile/idp')
},
unlinkIdentityProvider(idpId) {
return fetchDeleteRequest(`profile/idp/${idpId}`)
},
linkIdentityProvider(idpId) {
window.location.href = `${API_URL}profile/idp/${idpId}/link`
}
}

View File

@@ -3,8 +3,7 @@ import {
fetchPostRequest,
fetchPutRequest,
fetchDeleteRequest,
fetchPostFileRequest,
API_URL
fetchPostFileRequest
} from '@/utils/serviceUtils'
import { fetchPublicGetRequest } from '@/utils/servicePublicUtils'

View File

@@ -1,28 +0,0 @@
export function removeActiveModal(modalInstance) {
// Close the modal
if (modalInstance) {
modalInstance.hide()
modalInstance.dispose()
}
// Remove any remaining modal backdrops
const backdrops = document.querySelectorAll('.modal-backdrop')
for (const backdrop of backdrops) {
backdrop.remove()
}
}
export function resetBodyStylesIfNoActiveModals() {
const openModals = document.querySelectorAll('.modal.show')
if (openModals.length === 0) {
document.body.style.overflow = ''
document.body.style.paddingRight = ''
// Remove modal-open class and any Bootstrap modal-related data attributes
document.body.classList.remove('modal-open')
document.body.removeAttribute('data-bs-overflow')
document.body.removeAttribute('data-bs-padding-right')
document.body.removeAttribute('class')
document.body.removeAttribute('style')
}
}