diff --git a/.github/workflows/build-release-images.yml b/.github/workflows/build-release-images.yml new file mode 100644 index 0000000..3aacab8 --- /dev/null +++ b/.github/workflows/build-release-images.yml @@ -0,0 +1,62 @@ +name: "Build Release Images" + +on: + workflow_dispatch: + push: + branches: + - main + - hotfix/** + +jobs: + build-and-push: + runs-on: ubuntu-latest + environment: production + strategy: + fail-fast: false + matrix: + include: + - name: api + context: api + image: securelearning-api + target: prod + - name: web + context: web + image: securelearning-web + target: prod + - name: smtp + context: smtp + image: securelearning-smtp + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Compute tags + id: tags + run: | + set -euo pipefail + IMAGE="${{ vars.DOCKERHUB_USERNAME }}/${{ matrix.image }}" + SHA_TAG="$(git rev-parse --short HEAD)" + { + echo "image=$IMAGE" + echo "sha_tag=$SHA_TAG" + } >> "$GITHUB_OUTPUT" + + - name: Build and push ${{ matrix.name }} + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + target: ${{ matrix.target || '' }} + push: true + tags: | + ${{ steps.tags.outputs.image }}:latest + ${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.sha_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: ${{ matrix.name == 'web' && format('VITE_API_URL={0}\nVITE_KEYCLOAK_URL={1}\nVITE_BASE_PATH={2}\nVITE_WEB_URL={3}', vars.API_URL, vars.KEYCLOAK_URL, vars.VITE_BASE_PATH, vars.WEB_URL) || '' }} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d4fe49e..c43799a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -23,7 +23,7 @@ on: jobs: deploy: - runs-on: [self-hosted, linux, x64] + runs-on: [self-hosted, Linux, x64] environment: ${{ inputs.environment || 'production' }} steps: @@ -55,10 +55,113 @@ jobs: --days 3650 + - name: Write deployment .env + env: + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + MONGO_ROOT_USER: ${{ vars.MONGO_ROOT_USER }} + MONGO_ROOT_PASSWORD: ${{ secrets.MONGO_ROOT_PASSWORD }} + MONGO_DB: ${{ vars.MONGO_DB }} + MONGO_USER: ${{ vars.MONGO_USER }} + MONGO_PASSWORD: ${{ secrets.MONGO_PASSWORD }} + GARAGE_RPC_SECRET: ${{ secrets.GARAGE_RPC_SECRET }} + GARAGE_ACCESS_KEY_ID: ${{ secrets.GARAGE_ACCESS_KEY_ID }} + GARAGE_SECRET_ACCESS_KEY: ${{ secrets.GARAGE_SECRET_ACCESS_KEY }} + RABBITMQ_DEFAULT_USER: ${{ vars.RABBITMQ_DEFAULT_USER }} + RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_DEFAULT_PASS }} + RABBITMQ_API_USER: ${{ vars.RABBITMQ_API_USER }} + RABBITMQ_API_PASS: ${{ secrets.RABBITMQ_API_PASS }} + RABBITMQ_SMTP_USER: ${{ vars.RABBITMQ_SMTP_USER }} + RABBITMQ_SMTP_PASS: ${{ secrets.RABBITMQ_SMTP_PASS }} + KEYCLOAK_ADMIN: ${{ vars.KEYCLOAK_ADMIN }} + KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }} + CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} + GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} + RABBITMQ_QUEUE: ${{ vars.RABBITMQ_QUEUE }} + FILE_STORAGE_BACKEND: ${{ vars.FILE_STORAGE_BACKEND }} + GARAGE_S3_ENDPOINT: ${{ vars.GARAGE_S3_ENDPOINT }} + GARAGE_S3_PUBLIC_ENDPOINT: ${{ vars.GARAGE_S3_PUBLIC_ENDPOINT }} + GARAGE_S3_REGION: ${{ vars.GARAGE_S3_REGION }} + GARAGE_FORCE_PATH_STYLE: ${{ vars.GARAGE_FORCE_PATH_STYLE }} + GARAGE_BUCKET_CONTENT: ${{ vars.GARAGE_BUCKET_CONTENT }} + GARAGE_BUCKET_LOGOS: ${{ vars.GARAGE_BUCKET_LOGOS }} + GARAGE_CONTENT_PREFIX: ${{ vars.GARAGE_CONTENT_PREFIX }} + GARAGE_LOGOS_PREFIX: ${{ vars.GARAGE_LOGOS_PREFIX }} + PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }} + KC_HTTP_RELATIVE_PATH: ${{ vars.KC_HTTP_RELATIVE_PATH }} + VITE_BASE_PATH: ${{ vars.VITE_BASE_PATH }} + KC_HOSTNAME: ${{ vars.KC_HOSTNAME }} + KC_HOSTNAME_URL: ${{ vars.KC_HOSTNAME_URL }} + KEYCLOAK_INTERNAL_URL: ${{ vars.KEYCLOAK_INTERNAL_URL }} + KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }} + API_INTERNAL_URL: ${{ vars.API_INTERNAL_URL }} + API_URL: ${{ vars.API_URL }} + WEB_URL: ${{ vars.WEB_URL }} + KEYCLOAK_ISSUER_URL: ${{ vars.KEYCLOAK_ISSUER_URL }} + NGINX_PORT: ${{ vars.NGINX_PORT }} + SERVER_PORT: ${{ vars.SERVER_PORT }} + TLS_CERT_FILE: ${{ vars.TLS_CERT_FILE }} + TLS_KEY_FILE: ${{ vars.TLS_KEY_FILE }} + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} + GRAFANA_ADMIN_USER: ${{ vars.GRAFANA_ADMIN_USER }} + run: | + set -euo pipefail + { + printf 'POSTGRES_USER=%s\n' "$POSTGRES_USER" + printf 'POSTGRES_PASSWORD=%s\n' "$POSTGRES_PASSWORD" + printf 'POSTGRES_DB=%s\n' "$POSTGRES_DB" + printf 'MONGO_ROOT_USER=%s\n' "$MONGO_ROOT_USER" + printf 'MONGO_ROOT_PASSWORD=%s\n' "$MONGO_ROOT_PASSWORD" + printf 'MONGO_DB=%s\n' "$MONGO_DB" + printf 'MONGO_USER=%s\n' "$MONGO_USER" + printf 'MONGO_PASSWORD=%s\n' "$MONGO_PASSWORD" + printf 'GARAGE_RPC_SECRET=%s\n' "$GARAGE_RPC_SECRET" + printf 'GARAGE_ACCESS_KEY_ID=%s\n' "$GARAGE_ACCESS_KEY_ID" + printf 'GARAGE_SECRET_ACCESS_KEY=%s\n' "$GARAGE_SECRET_ACCESS_KEY" + printf 'FILE_STORAGE_BACKEND=%s\n' "$FILE_STORAGE_BACKEND" + printf 'GARAGE_S3_ENDPOINT=%s\n' "$GARAGE_S3_ENDPOINT" + printf 'GARAGE_S3_PUBLIC_ENDPOINT=%s\n' "$GARAGE_S3_PUBLIC_ENDPOINT" + printf 'GARAGE_S3_REGION=%s\n' "$GARAGE_S3_REGION" + printf 'GARAGE_FORCE_PATH_STYLE=%s\n' "$GARAGE_FORCE_PATH_STYLE" + printf 'GARAGE_BUCKET_CONTENT=%s\n' "$GARAGE_BUCKET_CONTENT" + printf 'GARAGE_BUCKET_LOGOS=%s\n' "$GARAGE_BUCKET_LOGOS" + printf 'GARAGE_CONTENT_PREFIX=%s\n' "$GARAGE_CONTENT_PREFIX" + printf 'GARAGE_LOGOS_PREFIX=%s\n' "$GARAGE_LOGOS_PREFIX" + printf 'RABBITMQ_DEFAULT_USER=%s\n' "$RABBITMQ_DEFAULT_USER" + printf 'RABBITMQ_DEFAULT_PASS=%s\n' "$RABBITMQ_DEFAULT_PASS" + printf 'RABBITMQ_QUEUE=%s\n' "$RABBITMQ_QUEUE" + printf 'RABBITMQ_API_USER=%s\n' "$RABBITMQ_API_USER" + printf 'RABBITMQ_API_PASS=%s\n' "$RABBITMQ_API_PASS" + printf 'RABBITMQ_SMTP_USER=%s\n' "$RABBITMQ_SMTP_USER" + printf 'RABBITMQ_SMTP_PASS=%s\n' "$RABBITMQ_SMTP_PASS" + printf 'KEYCLOAK_ADMIN=%s\n' "$KEYCLOAK_ADMIN" + printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$KEYCLOAK_ADMIN_PASSWORD" + printf 'CLIENT_SECRET=%s\n' "$CLIENT_SECRET" + printf 'KC_HOSTNAME=%s\n' "$KC_HOSTNAME" + printf 'KC_HOSTNAME_URL=%s\n' "$KC_HOSTNAME_URL" + printf 'KC_HTTP_RELATIVE_PATH=%s\n' "$KC_HTTP_RELATIVE_PATH" + printf 'KEYCLOAK_INTERNAL_URL=%s\n' "$KEYCLOAK_INTERNAL_URL" + printf 'KEYCLOAK_URL=%s\n' "$KEYCLOAK_URL" + printf 'API_INTERNAL_URL=%s\n' "$API_INTERNAL_URL" + printf 'PUBLIC_BASE_URL=%s\n' "$PUBLIC_BASE_URL" + printf 'VITE_BASE_PATH=%s\n' "$VITE_BASE_PATH" + printf 'API_URL=%s\n' "$API_URL" + printf 'WEB_URL=%s\n' "$WEB_URL" + printf 'KEYCLOAK_ISSUER_URL=%s\n' "$KEYCLOAK_ISSUER_URL" + printf 'NGINX_PORT=%s\n' "$NGINX_PORT" + printf 'SERVER_PORT=%s\n' "$SERVER_PORT" + printf 'TLS_CERT_FILE=%s\n' "$TLS_CERT_FILE" + printf 'TLS_KEY_FILE=%s\n' "$TLS_KEY_FILE" + printf 'DOCKERHUB_USERNAME=%s\n' "$DOCKERHUB_USERNAME" + printf 'GRAFANA_ADMIN_USER=%s\n' "$GRAFANA_ADMIN_USER" + printf 'GRAFANA_ADMIN_PASSWORD=%s\n' "$GRAFANA_ADMIN_PASSWORD" + printf 'NGINX_CONF_PATH=%s\n' "${{ inputs.nginx_config || 'nginx.mednat.conf' }}" + } > deployment/.env + - name: Rebuild and redeploy stack env: DOCKER_BUILDKIT: 1 - NGINX_CONF_PATH: ${{ inputs.nginx_config || 'nginx.mednat.conf' }} run: | set -euo pipefail COMPOSE_FILES="-f deployment/docker-compose.yml" @@ -67,4 +170,4 @@ jobs: fi docker compose $COMPOSE_FILES pull docker compose $COMPOSE_FILES down --remove-orphans - docker compose $COMPOSE_FILES up -d --remove-orphans \ No newline at end of file + docker compose $COMPOSE_FILES up -d --remove-orphans diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index 57c07af..482676d 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -76,6 +76,7 @@ jobs: build-and-push: needs: check runs-on: ubuntu-latest + environment: production if: github.event_name == 'push' steps: @@ -97,7 +98,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max build-args: | - VITE_API_URL=${{ secrets.VITE_API_URL }} - VITE_KEYCLOAK_URL=${{ secrets.VITE_KEYCLOAK_URL }} - VITE_BASE_PATH=${{ secrets.VITE_BASE_PATH }} - VITE_WEB_URL=${{ secrets.VITE_WEB_URL }} + VITE_API_URL=${{ vars.VITE_API_URL }} + VITE_KEYCLOAK_URL=${{ vars.VITE_KEYCLOAK_URL }} + VITE_BASE_PATH=${{ vars.VITE_BASE_PATH }} + VITE_WEB_URL=${{ vars.VITE_WEB_URL }} diff --git a/api/Dockerfile b/api/Dockerfile index ed0fed9..3788874 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -10,7 +10,7 @@ COPY pyproject.toml uv.lock /app/ WORKDIR /app # Install dependencies (frozen, no-cache for prod optimization) -RUN uv sync --frozen --no-cache +RUN uv sync --no-cache # Development target: hot reload with source mount FROM base AS dev diff --git a/api/src/main.py b/api/src/main.py index f7d9cf7..ef48b9c 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -22,13 +22,18 @@ phishing_kit, courses, progress, + certificates, ) from src.core.db import init_db from src.core.mongo import close_mongo_client from src.core.object_storage import ensure_bucket, garage_enabled from src.core.settings import settings from src.tasks import start_scheduler, shutdown_scheduler -from src.tasks.tracking_consumer import start_tracking_consumer, shutdown_tracking_consumer +from src.tasks.tracking_consumer import ( + start_tracking_consumer, + shutdown_tracking_consumer, +) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -45,6 +50,7 @@ async def lifespan(app: FastAPI): shutdown_tracking_consumer() await close_mongo_client() + app = FastAPI( title="Project Template API", summary="API to serve Project Template.", @@ -68,6 +74,7 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) + @app.get( "/health", tags=["Health"], @@ -90,3 +97,4 @@ async def health_check(): app.include_router(phishing_kit.router, prefix="/api", tags=["phishing-kits"]) app.include_router(courses.router, prefix="/api", tags=["courses"]) app.include_router(progress.router, prefix="/api", tags=["progress"]) +app.include_router(certificates.router, prefix="/api", tags=["certificates"]) diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index eea8972..6464e74 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -73,7 +73,7 @@ AcceptRequest, ComplianceStatusResponse, ) -from .user_progress import UserProgress, AssignmentStatus +from .user_progress import UserProgress, AssignmentStatus, CertificateDTO from .org_manager import ( OrgUserCreate, OrgGroupCreate, @@ -216,4 +216,5 @@ "PaginatedCourses", "UserProgress", "AssignmentStatus", + "CertificateDTO", ] diff --git a/api/src/models/user_progress/__init__.py b/api/src/models/user_progress/__init__.py index c4ebf96..ba4c89b 100644 --- a/api/src/models/user_progress/__init__.py +++ b/api/src/models/user_progress/__init__.py @@ -1,3 +1,4 @@ from .table import UserProgress, AssignmentStatus +from .schemas import CertificateDTO -__all__ = ["UserProgress", "AssignmentStatus"] +__all__ = ["UserProgress", "AssignmentStatus", "CertificateDTO"] diff --git a/api/src/models/user_progress/schemas.py b/api/src/models/user_progress/schemas.py new file mode 100644 index 0000000..51432e7 --- /dev/null +++ b/api/src/models/user_progress/schemas.py @@ -0,0 +1,15 @@ +from typing import Optional +from sqlmodel import SQLModel + + +class CertificateDTO(SQLModel): + user_id: str + course_id: str + last_emission_date: str + expiration_date: str + expired: bool = False + course_name: Optional["str"] + course_cover_image_link: Optional["str"] + difficulty: Optional["str"] + category: Optional["str"] + realm: str diff --git a/api/src/routers/certificates.py b/api/src/routers/certificates.py new file mode 100644 index 0000000..689170a --- /dev/null +++ b/api/src/routers/certificates.py @@ -0,0 +1,55 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from src.core.dependencies import SessionDep, OAuth2Scheme, CurrentRealm +from src.core.security import Roles, Resource, Scope +from src.services.compliance.token_helpers import decode_token_verified +from src.services import progress as progress_service + +router = APIRouter(prefix="/users", tags=["certificates"]) + + +@router.get( + "/me/certificates", + responses={401: {"description": "Invalid token"}}, +) +async def get_my_certificates( + session: SessionDep, + realm: CurrentRealm, + token: OAuth2Scheme, + include_expired: Annotated[bool, Query()] = False, +): + """Get certificates for the authenticated user.""" + claims = decode_token_verified(token) + user_id = claims.get("sub") + + if not user_id: + raise HTTPException(status_code=401, detail="Invalid token") + + return await progress_service.list_certificates( + user_id=user_id, + session=session, + realm_name=realm, + include_expired=include_expired, + ) + + +@router.get( + "/{user_id}/certificates", + dependencies=[Depends(Roles(Resource.ORG_MANAGER, Scope.VIEW))], + responses={401: {"description": "Invalid token"}}, +) +async def get_user_certificates( + user_id: str, + session: SessionDep, + realm: CurrentRealm, + token: OAuth2Scheme, + include_expired: Annotated[bool, Query()] = False, +): + """Get certificates for a user (org manager only).""" + return await progress_service.list_certificates( + user_id=user_id, + session=session, + realm_name=realm, + include_expired=include_expired, + ) diff --git a/api/src/routers/org_manager/user_routes.py b/api/src/routers/org_manager/user_routes.py index 2212328..7cd7ef6 100644 --- a/api/src/routers/org_manager/user_routes.py +++ b/api/src/routers/org_manager/user_routes.py @@ -96,6 +96,7 @@ def enroll_user_endpoint( start_date=payload.start_date, deadline=payload.deadline, cert_valid_days=payload.cert_valid_days, + realm_name=realm, ) diff --git a/api/src/services/org_manager/user_handler.py b/api/src/services/org_manager/user_handler.py index e145fbe..6c74244 100644 --- a/api/src/services/org_manager/user_handler.py +++ b/api/src/services/org_manager/user_handler.py @@ -293,6 +293,7 @@ def enroll_user( start_date=None, deadline=None, cert_valid_days=365, + realm_name: str | None = None, ) -> dict: """Assign courses to a user.""" from datetime import datetime, timedelta @@ -315,6 +316,7 @@ def enroll_user( deadline=deadline, cert_valid_days=cert_valid_days, status=AssignmentStatus.SCHEDULED, + realm_name=realm_name, overdue=False, expired=False, progress_data={}, @@ -332,7 +334,10 @@ def enroll_user( existing.deadline = deadline existing.cert_valid_days = cert_valid_days existing.overdue = False + existing.is_certified = False existing.expired = False + existing.cert_expires_at = None + existing.realm_name = realm_name if existing.status != AssignmentStatus.COMPLETED: existing.progress_data = {} existing.completed_sections = [] diff --git a/api/src/services/progress.py b/api/src/services/progress.py index 45b0fdf..143039d 100644 --- a/api/src/services/progress.py +++ b/api/src/services/progress.py @@ -1,7 +1,8 @@ -from sqlmodel import Session, select +from sqlmodel import Session, col, select from fastapi import HTTPException +from sqlalchemy import or_ from datetime import datetime, timedelta -from src.models import UserProgress, AssignmentStatus +from src.models import UserProgress, AssignmentStatus, CertificateDTO import asyncio from src.services.courses import get_course from src.services.modules import get_module @@ -37,7 +38,9 @@ def assign_course( existing.deadline = deadline existing.cert_valid_days = cert_valid_days existing.overdue = False + existing.is_certified = False existing.expired = False + existing.cert_expires_at = None existing.realm_name = realm_name if existing.status != AssignmentStatus.COMPLETED: existing.progress_data = {} @@ -150,6 +153,7 @@ async def complete_section( if len(progress.completed_sections) >= total_sections and total_sections > 0: progress.is_certified = True + progress.expired = False progress.status = AssignmentStatus.COMPLETED progress.cert_expires_at = datetime.utcnow() + timedelta( days=progress.cert_valid_days @@ -198,6 +202,8 @@ async def complete_refreshment( ) progress.is_certified = True + progress.expired = False + progress.overdue = False progress.status = AssignmentStatus.COMPLETED progress.cert_expires_at = datetime.utcnow() + timedelta( days=progress.cert_valid_days @@ -227,3 +233,66 @@ def mark_overdue(user_id: str, course_id: str, session: Session) -> UserProgress session.commit() session.refresh(progress) return progress + + +async def list_certificates( + user_id: str, + session: Session, + realm_name: str | None = None, + include_expired: bool = False, +) -> list[CertificateDTO]: + + query = select(UserProgress).where(UserProgress.user_id == user_id) + + if include_expired: + query = query.where( + or_( + col(UserProgress.is_certified) == True, + col(UserProgress.expired) == True, + ) + ) + else: + query = query.where( + col(UserProgress.is_certified) == True, + col(UserProgress.expired) == False, + ) + + if realm_name: + query = query.where( + or_( + col(UserProgress.realm_name) == realm_name, + col(UserProgress.realm_name).is_(None), + ) + ) + + certified_progresses = session.exec(query).all() + + certificates = [] + for progress in certified_progresses: + try: + course = await get_course(progress.course_id) + except HTTPException: + # Course was deleted; skip this certificate + continue + + # Format dates as ISO strings + emission_date = progress.updated_at.isoformat() if progress.updated_at else "" + expiration_date = ( + progress.cert_expires_at.isoformat() if progress.cert_expires_at else "" + ) + + cert_dto = CertificateDTO( + user_id=progress.user_id, + course_id=progress.course_id, + last_emission_date=emission_date, + expiration_date=expiration_date, + expired=progress.expired, + course_name=course.title, + course_cover_image_link=course.cover_image, + difficulty=course.difficulty, + category=course.category, + realm=progress.realm_name or "", + ) + certificates.append(cert_dto) + + return certificates diff --git a/api/src/tasks/scheduler.py b/api/src/tasks/scheduler.py index def4535..4570183 100644 --- a/api/src/tasks/scheduler.py +++ b/api/src/tasks/scheduler.py @@ -9,12 +9,20 @@ from sqlmodel import Session, col, select from src.core.db import engine -from src.models import Campaign, CampaignStatus, EmailSending, EmailSendingStatus, UserProgress, AssignmentStatus +from src.models import ( + Campaign, + CampaignStatus, + EmailSending, + EmailSendingStatus, + UserProgress, + AssignmentStatus, +) from src.services.campaign import CampaignService logger = logging.getLogger(__name__) + def process_course_assignments() -> None: """Process course assignments lifecycle (SCHEDULED -> ACTIVE -> OVERDUE).""" with Session(engine) as session: @@ -25,7 +33,7 @@ def process_course_assignments() -> None: scheduled_assignments = session.exec( select(UserProgress).where( UserProgress.status == AssignmentStatus.SCHEDULED, - UserProgress.start_date <= now + UserProgress.start_date <= now, ) ).all() @@ -33,7 +41,9 @@ def process_course_assignments() -> None: assignment.status = AssignmentStatus.ACTIVE assignment.notified_at = now # TODO: trigger direct notification service here - logger.info(f"Course assignment for user {assignment.user_id} and course {assignment.course_id} -> ACTIVE") + logger.info( + f"Course assignment for user {assignment.user_id} and course {assignment.course_id} -> ACTIVE" + ) updated_count += 1 # Active -> Overdue @@ -41,31 +51,36 @@ def process_course_assignments() -> None: select(UserProgress).where( UserProgress.status == AssignmentStatus.ACTIVE, UserProgress.deadline <= now, - UserProgress.is_certified == False + UserProgress.is_certified == False, ) ).all() for assignment in active_assignments: assignment.status = AssignmentStatus.OVERDUE assignment.overdue = True - logger.info(f"Course assignment for user {assignment.user_id} and course {assignment.course_id} -> OVERDUE") + logger.info( + f"Course assignment for user {assignment.user_id} and course {assignment.course_id} -> OVERDUE" + ) updated_count += 1 - + # Completed -> Renewal Required completed_assignments = session.exec( select(UserProgress).where( UserProgress.status == AssignmentStatus.COMPLETED, UserProgress.is_certified == True, - UserProgress.cert_expires_at <= now + UserProgress.cert_expires_at <= now, ) ).all() for assignment in completed_assignments: assignment.status = AssignmentStatus.RENEWAL_REQUIRED assignment.is_certified = False + assignment.expired = True assignment.completed_sections = [] assignment.progress_data = {} - logger.info(f"Course assignment for user {assignment.user_id} and course {assignment.course_id} -> RENEWAL_REQUIRED") + logger.info( + f"Course assignment for user {assignment.user_id} and course {assignment.course_id} -> RENEWAL_REQUIRED" + ) updated_count += 1 if updated_count > 0: @@ -73,8 +88,7 @@ def process_course_assignments() -> None: logger.info(f"Processed {updated_count} course assignment(s)") -# Global scheduler - +# Global scheduler _scheduler: BackgroundScheduler | None = None @@ -233,11 +247,11 @@ def process_pending_emails() -> None: try: # Send email to RabbitMQ campaign_service._send_email_to_rabbitmq(email, campaign) - + # Update status to queued email.status = EmailSendingStatus.QUEUED session.commit() - + except (ValueError, ValidationError) as e: # Irrecoverable payload/configuration issue for this email. email.status = EmailSendingStatus.FAILED @@ -246,7 +260,7 @@ def process_pending_emails() -> None: logger.error( f"Failed email {email.id} for campaign {email.campaign_id} marked FAILED: {e}" ) - + except Exception as e: logger.error( f"Failed to process email {email.id} for campaign {email.campaign_id}: {e}" diff --git a/api/test/services/test_progress_service.py b/api/test/services/test_progress_service.py index 95745f5..e2a7ebd 100644 --- a/api/test/services/test_progress_service.py +++ b/api/test/services/test_progress_service.py @@ -26,6 +26,7 @@ complete_section, complete_refreshment, mark_overdue, + list_certificates, ) from src.models import UserProgress, AssignmentStatus @@ -215,6 +216,7 @@ async def test_complete_section(session: Session): # Assert assert "sec-1" in res.completed_sections assert res.is_certified is True + assert res.expired is False assert res.status == AssignmentStatus.COMPLETED assert res.cert_expires_at is not None @@ -319,6 +321,7 @@ async def test_complete_refreshment_success(session: Session): # Assert assert res.is_certified is True + assert res.expired is False assert res.status == AssignmentStatus.COMPLETED @@ -395,3 +398,129 @@ async def test_complete_refreshment_fetch_error(session: Session): await complete_refreshment(uid, cid, session) assert exc.value.status_code == 400 assert "Not all refreshment sections completed" in exc.value.detail + + +@pytest.mark.anyio +async def test_list_certificates_filters_realm_and_expired(session: Session): + now = datetime.utcnow() + session.add_all( + [ + UserProgress( + user_id="u1", + course_id="c-valid", + is_certified=True, + expired=False, + realm_name="realm-a", + cert_expires_at=now + timedelta(days=10), + updated_at=now, + ), + UserProgress( + user_id="u1", + course_id="c-expired", + is_certified=True, + expired=True, + realm_name="realm-a", + cert_expires_at=now - timedelta(days=1), + updated_at=now, + ), + UserProgress( + user_id="u1", + course_id="c-other-realm", + is_certified=True, + expired=False, + realm_name="realm-b", + updated_at=now, + ), + ] + ) + session.commit() + + mock_course = MagicMock() + mock_course.title = "Security Fundamentals" + mock_course.cover_image = "cover-id" + mock_course.difficulty = "Easy" + mock_course.category = "Awareness" + + with patch("src.services.progress.get_course", new_callable=AsyncMock) as mock_get: + mock_get.return_value = mock_course + result = await list_certificates( + user_id="u1", + session=session, + realm_name="realm-a", + ) + + assert len(result) == 1 + assert result[0].course_id == "c-valid" + assert result[0].course_name == "Security Fundamentals" + assert result[0].realm == "realm-a" + assert result[0].expired is False + + +@pytest.mark.anyio +async def test_list_certificates_includes_expired_when_flag_true(session: Session): + now = datetime.utcnow() + session.add_all( + [ + UserProgress( + user_id="u1", + course_id="c-valid", + is_certified=True, + expired=False, + realm_name="realm-a", + cert_expires_at=now + timedelta(days=10), + updated_at=now, + ), + UserProgress( + user_id="u1", + course_id="c-expired", + is_certified=True, + expired=True, + realm_name="realm-a", + cert_expires_at=now - timedelta(days=1), + updated_at=now, + ), + ] + ) + session.commit() + + mock_course = MagicMock() + mock_course.title = "Security Fundamentals" + mock_course.cover_image = "cover-id" + mock_course.difficulty = "Easy" + mock_course.category = "Awareness" + + with patch("src.services.progress.get_course", new_callable=AsyncMock) as mock_get: + mock_get.return_value = mock_course + result = await list_certificates( + user_id="u1", + session=session, + realm_name="realm-a", + include_expired=True, + ) + + assert len(result) == 2 + assert {item.course_id for item in result} == {"c-valid", "c-expired"} + + +@pytest.mark.anyio +async def test_list_certificates_skips_missing_course(session: Session): + session.add( + UserProgress( + user_id="u1", + course_id="missing-course", + is_certified=True, + expired=False, + realm_name="realm-a", + ) + ) + session.commit() + + with patch("src.services.progress.get_course", new_callable=AsyncMock) as mock_get: + mock_get.side_effect = HTTPException(status_code=404, detail="Course not found") + result = await list_certificates( + user_id="u1", + session=session, + realm_name="realm-a", + ) + + assert result == [] diff --git a/api/test/test_certificate_endpoints.py b/api/test/test_certificate_endpoints.py new file mode 100644 index 0000000..5b4d577 --- /dev/null +++ b/api/test/test_certificate_endpoints.py @@ -0,0 +1,147 @@ +import os + +os.environ.update( + { + "POSTGRES_SERVER": "localhost", + "POSTGRES_USER": "testuser", + "POSTGRES_PASSWORD": "testpassword", + "RABBITMQ_HOST": "localhost", + "RABBITMQ_USER": "guest", + "RABBITMQ_PASS": "guest", + "RABBITMQ_QUEUE": "email_queue", + "KEYCLOAK_URL": "http://fake-keycloak:8080", + "CLIENT_SECRET": "fake-secret", + } +) + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import AsyncMock, patch, MagicMock + +from src.main import app +from src.core.dependencies import get_db, get_current_realm +from src.core.security import oauth_2_scheme +from src.routers import certificates as certificates_router + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def override_dependencies(): + mock_session = MagicMock() + app.dependency_overrides[get_db] = lambda: mock_session + app.dependency_overrides[oauth_2_scheme] = lambda: "fake-token" + app.dependency_overrides[get_current_realm] = lambda: "test-realm" + + yield + + app.dependency_overrides = {} + + +@pytest.mark.anyio +async def test_get_my_certificates_endpoint(): + expected = [ + { + "user_id": "user-123", + "course_id": "course-1", + "last_emission_date": "2026-04-19T10:00:00", + "expiration_date": "2027-04-19T10:00:00", + "expired": False, + "course_name": "Course A", + "course_cover_image_link": "cover-1", + "difficulty": "Easy", + "category": "Awareness", + "realm": "test-realm", + } + ] + + with patch("src.routers.certificates.decode_token_verified") as mock_decode: + with patch( + "src.services.progress.list_certificates", new_callable=AsyncMock + ) as mock_list: + mock_decode.return_value = {"sub": "user-123"} + mock_list.return_value = expected + + response = client.get("/api/users/me/certificates") + + assert response.status_code == 200 + assert response.json() == expected + mock_list.assert_awaited_once_with( + user_id="user-123", + session=app.dependency_overrides[get_db](), + realm_name="test-realm", + include_expired=False, + ) + + +@pytest.mark.anyio +async def test_get_user_certificates_endpoint_function_as_org_manager(): + expected = [ + { + "user_id": "user-456", + "course_id": "course-2", + "last_emission_date": "2026-04-19T10:00:00", + "expiration_date": "2027-04-19T10:00:00", + "expired": False, + "course_name": "Course B", + "course_cover_image_link": "cover-2", + "difficulty": "Medium", + "category": "Phishing", + "realm": "test-realm", + } + ] + + mock_session = MagicMock() + with patch( + "src.services.progress.list_certificates", new_callable=AsyncMock + ) as mock_list: + mock_list.return_value = expected + + result = await certificates_router.get_user_certificates( + user_id="user-456", + session=mock_session, + realm="test-realm", + token="fake-token", + include_expired=True, + ) + + assert result == expected + mock_list.assert_awaited_once_with( + user_id="user-456", + session=mock_session, + realm_name="test-realm", + include_expired=True, + ) + + +@pytest.mark.anyio +async def test_get_my_certificates_missing_sub_returns_401(): + with patch("src.routers.certificates.decode_token_verified") as mock_decode: + mock_decode.return_value = {} + response = client.get("/api/users/me/certificates") + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid token" + + +@pytest.mark.anyio +async def test_get_my_certificates_include_expired_true_is_forwarded(): + expected = [] + + with patch("src.routers.certificates.decode_token_verified") as mock_decode: + with patch( + "src.services.progress.list_certificates", new_callable=AsyncMock + ) as mock_list: + mock_decode.return_value = {"sub": "user-123"} + mock_list.return_value = expected + + response = client.get("/api/users/me/certificates?include_expired=true") + + assert response.status_code == 200 + assert response.json() == expected + mock_list.assert_awaited_once_with( + user_id="user-123", + session=app.dependency_overrides[get_db](), + realm_name="test-realm", + include_expired=True, + ) diff --git a/api/uv.lock b/api/uv.lock index d15106c..eb5bc1d 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -49,6 +49,7 @@ dependencies = [ { name = "fastapi", extra = ["all"] }, { name = "motor" }, { name = "pika" }, + { name = "prometheus-fastapi-instrumentator" }, { name = "psycopg", extra = ["binary", "pool"] }, { name = "pyjwt" }, { name = "pypdf" }, @@ -60,6 +61,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -70,6 +72,7 @@ requires-dist = [ { name = "fastapi", extras = ["all"], specifier = ">=0.119.0" }, { name = "motor", specifier = ">=3.6.0" }, { name = "pika", specifier = ">=1.3.0" }, + { name = "prometheus-fastapi-instrumentator", specifier = ">=0.9.0" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.11" }, { name = "pyjwt", specifier = ">=2.9.0" }, { name = "pypdf" }, @@ -81,6 +84,7 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.8.0" }, ] [[package]] @@ -592,7 +596,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -601,7 +604,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -610,7 +612,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -619,7 +620,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -863,6 +863,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/6d/24d53033cf93826aa7857699a4450c1c67e5b9c710e925b1ed2b320c04df/prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e", size = 20220, upload-time = "2025-03-19T19:35:05.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/72/0824c18f3bc75810f55dacc2dd933f6ec829771180245ae3cc976195dec0/prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9", size = 19296, upload-time = "2025-03-19T19:35:04.323Z" }, +] + [[package]] name = "psycopg" version = "3.3.3" @@ -1357,6 +1379,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + [[package]] name = "s3transfer" version = "0.16.0" diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 9a280ab..4b8a474 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -69,11 +69,11 @@ services: retries: 5 garage: - build: - context: ./services/garage - dockerfile: Dockerfile + image: dxflrs/garage:76592723deb9285a071320c40842f6be61e924fd container_name: sl-garage restart: unless-stopped + entrypoint: ["/garage"] + command: ["server", "--single-node", "--default-bucket"] environment: GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET} GARAGE_DEFAULT_ACCESS_KEY: ${GARAGE_ACCESS_KEY_ID} @@ -83,6 +83,7 @@ services: - "3900:3900" - "3901:3901" volumes: + - ./services/garage/garage.toml:/etc/garage.toml:ro - garage_data:/var/lib/garage healthcheck: test: [ "CMD", "/garage", "-c", "/etc/garage.toml", "status" ] diff --git a/deployment/services/garage/Dockerfile b/deployment/services/garage/Dockerfile deleted file mode 100644 index 258e192..0000000 --- a/deployment/services/garage/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM dxflrs/garage:76592723deb9285a071320c40842f6be61e924fd - -# Install envsubst -RUN apk add --no-cache gettext - -# Copy the template and init script -COPY garage.template.toml /opt/garage.template.toml -COPY init.sh /usr/local/bin/init.sh - -RUN chmod +x /usr/local/bin/init.sh - -ENTRYPOINT ["/usr/local/bin/init.sh"] -CMD ["server", "--single-node", "--default-bucket"] diff --git a/deployment/services/garage/garage.template.toml b/deployment/services/garage/garage.toml similarity index 89% rename from deployment/services/garage/garage.template.toml rename to deployment/services/garage/garage.toml index 8b2595a..dc4405e 100644 --- a/deployment/services/garage/garage.template.toml +++ b/deployment/services/garage/garage.toml @@ -8,7 +8,6 @@ compression_level = 2 rpc_bind_addr = "[::]:3901" rpc_public_addr = "garage:3901" -rpc_secret = "${GARAGE_RPC_SECRET}" [s3_api] s3_region = "garage" diff --git a/deployment/services/garage/init.sh b/deployment/services/garage/init.sh deleted file mode 100644 index 88eaf09..0000000 --- a/deployment/services/garage/init.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -e - -# Generate garage.toml from template using environment variables -envsubst < /opt/garage.template.toml > /etc/garage.toml - -# Execute the main garage command -exec /garage "$@" diff --git a/web/src/components/admin/tenant-org-manager/UserCertificatesList.tsx b/web/src/components/admin/tenant-org-manager/UserCertificatesList.tsx new file mode 100644 index 0000000..e41cb4b --- /dev/null +++ b/web/src/components/admin/tenant-org-manager/UserCertificatesList.tsx @@ -0,0 +1,129 @@ +import { AlertTriangle, Award, CheckCircle2, Loader2 } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { UserCertificateDto } from "@/types/tenantOrgManager"; +import { formatCertificateDate } from "./userDetailsUtils"; + +// ─── Individual certificate card ────────────────────────────────────────────── + +function CertificateCard({ certificate }: { readonly certificate: UserCertificateDto }) { + return ( +
+
+ + + +
+ + + ); +} + +// ─── Public component ───────────────────────────────────────────────────────── + +interface UserCertificatesListProps { + readonly certificates: UserCertificateDto[]; + readonly loading: boolean; + readonly error: string | null; +} + +export function UserCertificatesList({ certificates, loading, error }: UserCertificatesListProps) { + let content: React.ReactNode; + + if (loading) { + content = ( +
+ + Loading certificates... +
+ ); + } else if (error) { + content =
{error}
; + } else if (certificates.length === 0) { + content = ( +
+ +

No certificates found

+

This user has not earned any certificates yet.

+
+ ); + } else { + content = ( +
+ {certificates.map((certificate) => ( + + ))} +
+ ); + } + + return
{content}
; +} diff --git a/web/src/components/admin/tenant-org-manager/UserDetailsGrid.tsx b/web/src/components/admin/tenant-org-manager/UserDetailsGrid.tsx new file mode 100644 index 0000000..d4a9008 --- /dev/null +++ b/web/src/components/admin/tenant-org-manager/UserDetailsGrid.tsx @@ -0,0 +1,165 @@ +import { AlertTriangle, CheckCircle2, Mail } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { TenantUserDetailDto } from "@/types/tenantOrgManager"; +import { + type DetailRow, + type DetailRowValue, + formatRoleLabel, + getRoleBadgeVariantProps, +} from "./userDetailsUtils"; + +// ─── Sub-renderers ──────────────────────────────────────────────────────────── + +function renderDetailValue(value: DetailRowValue) { + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + if (value === null || value === undefined || value === "") { + return Not set; + } + + return value; +} + +function RoleBadge({ role }: { readonly role?: string | null }) { + if (!role) { + return Not set; + } + + const { variant, className } = getRoleBadgeVariantProps(role); + return ( + + {formatRoleLabel(role)} + + ); +} + +function EmailVerificationIcon({ verified }: { readonly verified?: boolean }) { + return ( + + + + + {verified ? ( + + ) : ( + + )} + + + + {verified ? "this email has been verified." : "this email has not been verified."} + + + + ); +} + +function DetailRowCard({ row, user }: { readonly row: DetailRow; readonly user: TenantUserDetailDto | null }) { + const isEmail = row.label === "Email"; + const isRole = row.label === "Role"; + + return ( +
+
+ {isEmail ? : } + {row.label} +
+ + {isEmail && ( +
+
+ {renderDetailValue(row.value)} +
+ +
+ )} + + {isRole && ( +
+ +
+ )} + + {!isEmail && !isRole && ( +
+ {renderDetailValue(row.value)} +
+ )} +
+ ); +} + +// ─── Groups panel ───────────────────────────────────────────────────────────── + +function GroupsPanel({ user }: { readonly user: TenantUserDetailDto | null }) { + return ( +
+
+ + Groups ({user?.groups?.length ?? 0}) +
+ +
+ {user?.groups?.length ? ( + user.groups.map((group, index) => { + const groupLabel = group.name || "Unnamed group"; + + if (!group.id) { + return ( + + {groupLabel} + + ); + } + + return ( + + + {groupLabel} + + + ); + }) + ) : ( + No groups assigned + )} +
+
+ ); +} + +// ─── Public component ───────────────────────────────────────────────────────── + +interface UserDetailsGridProps { + readonly user: TenantUserDetailDto | null; + readonly detailRows: DetailRow[]; +} + +export function UserDetailsGrid({ user, detailRows }: UserDetailsGridProps) { + return ( +
+
+ {detailRows.map((row) => ( + + ))} +
+ + +
+ ); +} diff --git a/web/src/components/admin/tenant-org-manager/UserDetailsHeader.tsx b/web/src/components/admin/tenant-org-manager/UserDetailsHeader.tsx new file mode 100644 index 0000000..bcc5732 --- /dev/null +++ b/web/src/components/admin/tenant-org-manager/UserDetailsHeader.tsx @@ -0,0 +1,35 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import RefreshButton from "@/components/shared/RefreshButton"; + +interface UserDetailsHeaderProps { + readonly displayName: string; + readonly isRefreshing: boolean; + readonly onRefresh: () => void; +} + +export function UserDetailsHeader({ displayName, isRefreshing, onRefresh }: UserDetailsHeaderProps) { + return ( +
+
+
+ + + +
+

{displayName}

+

User details

+
+
+ + +
+
+ ); +} diff --git a/web/src/components/admin/tenant-org-manager/UserDetailsPage.tsx b/web/src/components/admin/tenant-org-manager/UserDetailsPage.tsx index 7f8f534..584f49a 100644 --- a/web/src/components/admin/tenant-org-manager/UserDetailsPage.tsx +++ b/web/src/components/admin/tenant-org-manager/UserDetailsPage.tsx @@ -1,96 +1,18 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Link, useParams } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "@tanstack/react-router"; import { useKeycloak } from "@react-keycloak/web"; -import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2, Mail } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Loader2 } from "lucide-react"; import { SectionSeparator } from "@/components/shared/SectionSeparator"; import { userApi } from "@/services/userApi"; import type { CampaignUserSending } from "@/services/campaignsApi"; -import type { TenantUserDetailDto } from "@/types/tenantOrgManager"; - -const getStatusBadgeVariant = (status: string) => { - switch (status.toLowerCase()) { - case "sent": - return "secondary"; - case "opened": - return "outline"; - case "clicked": - case "phished": - return "destructive"; - default: - return "outline"; - } -}; - -const formatRoleLabel = (role?: string | null) => { - if (!role) { - return "Not set"; - } - - const normalizedRole = role.toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); - - if (normalizedRole === "org_manager") { - return "Org Manager"; - } - - if (normalizedRole === "user") { - return "User"; - } - - return role - .split(/[_-]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(" "); -}; - -const getRoleBadge = (role?: string | null) => { - if (!role) { - return Not set; - } - - const normalizedRole = role.toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); - - if (normalizedRole === "org_manager") { - return ( - - {formatRoleLabel(role)} - - ); - } - - if (normalizedRole === "user") { - return {formatRoleLabel(role)}; - } - - return {formatRoleLabel(role)}; -}; - -const getMostRecentStatusDate = (sending: CampaignUserSending) => { - const candidates = [sending.phished_at, sending.clicked_at, sending.opened_at, sending.sent_at] - .filter((value): value is string => Boolean(value)) - .map((value) => ({ - value, - time: new Date(value).getTime(), - })) - .filter((entry) => !Number.isNaN(entry.time)); - - if (candidates.length === 0) { - return "-"; - } - - const mostRecent = candidates.reduce((latest, current) => (current.time > latest.time ? current : latest), candidates[0]); - return new Date(mostRecent.value).toLocaleDateString(); -}; - -type DetailRowValue = string | number | boolean | null | undefined; - -interface DetailRow { - label: string; - value: DetailRowValue; -} +import type { TenantUserDetailDto, UserCertificateDto } from "@/types/tenantOrgManager"; + +import { UserDetailsHeader } from "./UserDetailsHeader"; +import { UserProfileCard } from "./UserProfileCard"; +import { UserDetailsGrid } from "./UserDetailsGrid"; +import { UserSendingsTable } from "./UserSendingsTable"; +import { UserCertificatesList } from "./UserCertificatesList"; export function UserDetailsPage() { const params = useParams({ from: "/users/$id" }); @@ -99,10 +21,17 @@ export function UserDetailsPage() { const [user, setUser] = useState(null); const [sendings, setSendings] = useState([]); + const [certificates, setCertificates] = useState([]); + const [loading, setLoading] = useState(true); const [loadingSendings, setLoadingSendings] = useState(true); + const [loadingCertificates, setLoadingCertificates] = useState(true); + const [error, setError] = useState(null); const [sendingsError, setSendingsError] = useState(null); + const [certificatesError, setCertificatesError] = useState(null); + + const [refreshCount, setRefreshCount] = useState(0); const realm = useMemo(() => { const iss = (keycloak.tokenParsed as { iss?: string } | undefined)?.iss; @@ -113,9 +42,7 @@ export function UserDetailsPage() { useEffect(() => { const loadUser = async () => { - if (!realm || !userId) { - return; - } + if (!realm || !userId) return; setLoading(true); setError(null); @@ -132,13 +59,11 @@ export function UserDetailsPage() { }; void loadUser(); - }, [realm, userId, keycloak.token]); + }, [realm, userId, keycloak.token, refreshCount]); useEffect(() => { const loadSendings = async () => { - if (!realm || !userId) { - return; - } + if (!realm || !userId) return; setLoadingSendings(true); setSendingsError(null); @@ -155,147 +80,50 @@ export function UserDetailsPage() { }; void loadSendings(); - }, [realm, userId, keycloak.token]); + }, [realm, userId, keycloak.token, refreshCount]); - const displayName = [user?.firstName, user?.lastName].filter(Boolean).join(" ").trim() - || user?.username - || userId; + useEffect(() => { + const loadCertificates = async () => { + if (!userId) return; - const detailRows: DetailRow[] = [ - { label: "Username", value: user?.username }, - { label: "Email", value: user?.email }, - { label: "Role", value: user?.role }, - { label: "Realm", value: user?.realm || realm }, - ]; + setLoadingCertificates(true); + setCertificatesError(null); - const renderDetailValue = (value: string | number | boolean | null | undefined) => { - if (typeof value === "boolean") { - return value ? "Yes" : "No"; - } + try { + const details = await userApi.getUserCertificates(userId, true); + setCertificates(details); + } catch (err) { + console.error("Failed to load user certificates", err); + setCertificatesError("Failed to load certificates."); + } finally { + setLoadingCertificates(false); + } + }; - if (value === null || value === undefined || value === "") { - return Not set; - } + void loadCertificates(); + }, [userId, keycloak.token, refreshCount]); - return value; + const handleRefresh = () => { + setRefreshCount((c) => c + 1); }; - const renderDetailContent = (row: DetailRow) => { - if (row.label === "Email") { - return ( -
-
- {renderDetailValue(row.value)} -
- - - - - - {user?.email_verified ? ( - - ) : ( - - )} - - - - {user?.email_verified - ? "this email has been verified." - : "this email has not been verified."} - - - -
- ); - } - - if (row.label === "Role") { - return ( -
- {getRoleBadge(user?.role ?? (user?.is_org_manager ? "org_manager" : null))} -
- ); - } - - return ( -
- {renderDetailValue(row.value)} -
- ); - }; + const displayName = + [user?.firstName, user?.lastName].filter(Boolean).join(" ").trim() || user?.username || userId; - let sendingsTableBody: ReactNode; - - if (loadingSendings) { - sendingsTableBody = ( - - - Loading email sendings... - - - ); - } else if (sendingsError) { - sendingsTableBody = ( - - - {sendingsError} - - - ); - } else if (sendings.length === 0) { - sendingsTableBody = ( - - - No email sendings found for this user. - - - ); - } else { - sendingsTableBody = ( - <> - {sendings.map((sending, index) => ( - - - - {sending.status} - - - {getMostRecentStatusDate(sending)} - - {sending.campaign_id ? ( - - {sending.campaign_name || "View campaign"} - - ) : ( - - {sending.campaign_name || "No campaign"} - - )} - - - ))} - - ); - } + const detailRows = [ + { label: "Username", value: user?.username }, + { label: "Email", value: user?.email }, + { label: "Role", value: user?.role }, + { label: "Realm", value: user?.realm || realm }, + ]; return (
-
-
- - - -
-

{displayName}

-

User details

-
-
-
+
@@ -312,107 +140,27 @@ export function UserDetailsPage() {
)} -
-
-
- -

{displayName}

-

{user?.email || "No email available"}

-
- -
- - - {user?.active === false ? "Inactive" : "Active"} - -
-
-
+ -
-
- {detailRows.map((row) => ( -
-
- {row.label === "Email" ? : } - {row.label} -
- {renderDetailContent(row)} -
- ))} -
- -
-
- - Groups ({user?.groups?.length ?? 0}) -
- -
- {user?.groups?.length ? ( - user.groups.map((group, index) => { - const groupLabel = group.name || "Unnamed group"; - - if (!group.id) { - return ( - - {groupLabel} - - ); - } - - return ( - - - {groupLabel} - - - ); - }) - ) : ( - No groups assigned - )} -
-
-
+ -
-
- - - - - - - - - - {sendingsTableBody} - -
- Status - DateCampaign
-
-
+ -
-
- put certificate content here -
-
+
diff --git a/web/src/components/admin/tenant-org-manager/UserProfileCard.tsx b/web/src/components/admin/tenant-org-manager/UserProfileCard.tsx new file mode 100644 index 0000000..b0ed897 --- /dev/null +++ b/web/src/components/admin/tenant-org-manager/UserProfileCard.tsx @@ -0,0 +1,34 @@ +import { CheckCircle2 } from "lucide-react"; +import type { TenantUserDetailDto } from "@/types/tenantOrgManager"; + +interface UserProfileCardProps { + readonly user: TenantUserDetailDto | null; + readonly displayName: string; +} + +export function UserProfileCard({ user, displayName }: UserProfileCardProps) { + const isActive = user?.active !== false; + + return ( +
+
+
+

{displayName}

+

{user?.email || "No email available"}

+
+ +
+ + + {isActive ? "Active" : "Inactive"} + +
+
+
+ ); +} diff --git a/web/src/components/admin/tenant-org-manager/UserSendingsTable.tsx b/web/src/components/admin/tenant-org-manager/UserSendingsTable.tsx new file mode 100644 index 0000000..3939436 --- /dev/null +++ b/web/src/components/admin/tenant-org-manager/UserSendingsTable.tsx @@ -0,0 +1,96 @@ +import { Link } from "@tanstack/react-router"; +import { Badge } from "@/components/ui/badge"; +import type { CampaignUserSending } from "@/services/campaignsApi"; +import { getStatusBadgeVariant, getMostRecentStatusDate } from "./userDetailsUtils"; + +interface UserSendingsTableProps { + readonly sendings: CampaignUserSending[]; + readonly loading: boolean; + readonly error: string | null; +} + +function SendingsTableBody({ sendings, loading, error }: UserSendingsTableProps) { + if (loading) { + return ( + + + Loading email sendings... + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } + + if (sendings.length === 0) { + return ( + + + No email sendings found for this user. + + + ); + } + + return ( + <> + {sendings.map((sending, index) => ( + + + + {sending.status} + + + {getMostRecentStatusDate(sending)} + + {sending.campaign_id ? ( + + {sending.campaign_name || "View campaign"} + + ) : ( + + {sending.campaign_name || "No campaign"} + + )} + + + ))} + + ); +} + +export function UserSendingsTable({ sendings, loading, error }: UserSendingsTableProps) { + return ( +
+
+ + + + + + + + + + + +
StatusDateCampaign
+
+
+ ); +} diff --git a/web/src/components/admin/tenant-org-manager/userDetailsUtils.ts b/web/src/components/admin/tenant-org-manager/userDetailsUtils.ts new file mode 100644 index 0000000..ec02b19 --- /dev/null +++ b/web/src/components/admin/tenant-org-manager/userDetailsUtils.ts @@ -0,0 +1,101 @@ +import type { CampaignUserSending } from "@/services/campaignsApi"; + +// ─── Badge / role helpers ──────────────────────────────────────────────────── + +export const getStatusBadgeVariant = (status: string) => { + switch (status.toLowerCase()) { + case "sent": + return "secondary"; + case "opened": + return "outline"; + case "clicked": + case "phished": + return "destructive"; + default: + return "outline"; + } +}; + +export const formatRoleLabel = (role?: string | null) => { + if (!role) { + return "Not set"; + } + + const normalizedRole = role.toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); + + if (normalizedRole === "org_manager") { + return "Org Manager"; + } + + if (normalizedRole === "user") { + return "User"; + } + + return role + .split(/[_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +}; + +export const getRoleBadgeVariantProps = ( + role?: string | null, +): { variant: "outline" | "secondary"; className?: string } => { + if (!role) return { variant: "outline" }; + + const normalizedRole = role.toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); + + if (normalizedRole === "org_manager") { + return { + variant: "secondary", + className: "border-violet-500/20 bg-violet-500 text-white hover:bg-violet-500/90", + }; + } + + if (normalizedRole === "user") { + return { variant: "outline" }; + } + + return { variant: "secondary" }; +}; + +// ─── Date helpers ──────────────────────────────────────────────────────────── + +export const getMostRecentStatusDate = (sending: CampaignUserSending): string => { + const candidates = [sending.phished_at, sending.clicked_at, sending.opened_at, sending.sent_at] + .filter((value): value is string => Boolean(value)) + .map((value) => ({ + value, + time: new Date(value).getTime(), + })) + .filter((entry) => !Number.isNaN(entry.time)); + + if (candidates.length === 0) { + return "-"; + } + + const mostRecent = candidates.reduce( + (latest, current) => (current.time > latest.time ? current : latest), + candidates[0], + ); + return new Date(mostRecent.value).toLocaleDateString(); +}; + +export const formatCertificateDate = (date: string): string => { + const parsedDate = new Date(date); + + if (Number.isNaN(parsedDate.getTime())) { + return "-"; + } + + return parsedDate.toLocaleDateString(); +}; + +// ─── Detail value renderer ─────────────────────────────────────────────────── + +export type DetailRowValue = string | number | boolean | null | undefined; + +export interface DetailRow { + label: string; + value: DetailRowValue; +} diff --git a/web/src/services/userApi.ts b/web/src/services/userApi.ts index 27d2bcc..4aa82a7 100644 --- a/web/src/services/userApi.ts +++ b/web/src/services/userApi.ts @@ -3,6 +3,7 @@ import type { CampaignUserSending } from "@/services/campaignsApi"; import type { CreateTenantUserPayload, CreateTenantUserResponse, + UserCertificateDto, TenantUserDetailDto, TenantUserListResponse, } from "@/types/tenantOrgManager"; @@ -21,6 +22,19 @@ export const userApi = { `/org-manager/${encodeURIComponent(realm)}/users/${encodeURIComponent(userId)}/sendings` ), + getUserCertificates: (userId: string, includeExpired = true) => { + const params = new URLSearchParams(); + + if (includeExpired) { + params.set("include_expired", "true"); + } + + const query = params.toString(); + const path = `/users/${encodeURIComponent(userId)}/certificates`; + + return apiClient.get(query ? `${path}?${query}` : path); + }, + createUser: (realm: string, payload: CreateTenantUserPayload) => apiClient.post( `/realms/${encodeURIComponent(realm)}/users`, diff --git a/web/src/types/tenantOrgManager.ts b/web/src/types/tenantOrgManager.ts index ff1d281..ada1d1d 100644 --- a/web/src/types/tenantOrgManager.ts +++ b/web/src/types/tenantOrgManager.ts @@ -51,3 +51,16 @@ export interface CreateTenantUserResponse { status: string; temporary_password: string; } + +export interface UserCertificateDto { + user_id: string; + course_id: string; + last_emission_date: string; + expiration_date: string; + expired: boolean; + course_name?: string | null; + course_cover_image_link?: string | null; + difficulty?: string | null; + category?: string | null; + realm: string; +}