mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-07 23:13:57 -05:00
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:
12
README.md
12
README.md
@@ -7,6 +7,7 @@
|
||||

|
||||
[](https://github.com/joaovitoriasilva/endurain/releases)
|
||||
[](https://github.com/joaovitoriasilva/endurain/stargazers)
|
||||
[](./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.
|
||||
@@ -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
96
TRADEMARK.md
Normal 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 software’s 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)],
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://star-history.com/#joaovitoriasilva/endurain&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.
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
256
frontend/app/src/components/Modals/ModalComponentMFASetup.vue
Normal file
256
frontend/app/src/components/Modals/ModalComponentMFASetup.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
fetchPostRequest,
|
||||
fetchPutRequest,
|
||||
fetchDeleteRequest,
|
||||
fetchPostFileRequest,
|
||||
API_URL
|
||||
fetchPostFileRequest
|
||||
} from '@/utils/serviceUtils'
|
||||
import { fetchPublicGetRequest } from '@/utils/servicePublicUtils'
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user