diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 91e1dfc5..e66d2a89 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -27,6 +27,9 @@ jobs: - name: Install dependencies run: uv sync --group dev + - name: Copy to .env + run: cp .env.dev.example .env + - name: Run tests run: uv run pytest diff --git a/api/Dockerfile b/api/Dockerfile index dba8fb1f..ba4a56da 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -6,12 +6,15 @@ RUN apk add --no-cache wget # Install uv. COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -# Copy the application into the container. -COPY . /app +# Copy the dependency files. +COPY pyproject.toml uv.lock /app/ # Install the application dependencies. WORKDIR /app RUN uv sync --frozen --no-cache +# Copy the rest of the application into the container. +COPY . /app + # Run the application. CMD ["/app/.venv/bin/fastapi", "run", "src/main.py", "--port", "80", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/api/src/core/security.py b/api/src/core/security.py index 68f55d06..9da0e9a4 100644 --- a/api/src/core/security.py +++ b/api/src/core/security.py @@ -9,10 +9,13 @@ from fastapi.security import OAuth2PasswordBearer +from src.core.settings import settings + + oauth_2_scheme = OAuth2PasswordBearer(tokenUrl="token") -AUTH_SERVER_URL = os.getenv("KEYCLOAK_URL") -KEYCLOAK_ISSUER_URL = os.getenv("KEYCLOAK_ISSUER_URL", AUTH_SERVER_URL) +AUTH_SERVER_URL = settings.KEYCLOAK_URL +KEYCLOAK_ISSUER_URL = settings.KEYCLOAK_ISSUER_URL or AUTH_SERVER_URL RESOURCE_SERVER_ID = "api" _JWKS_CLIENTS: dict[str, PyJWKClient] = {} diff --git a/api/src/core/settings.py b/api/src/core/settings.py index 982ea12e..847a467b 100644 --- a/api/src/core/settings.py +++ b/api/src/core/settings.py @@ -17,6 +17,15 @@ class Settings(BaseSettings): POSTGRES_USER: str POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" + + # Keycloak + KEYCLOAK_URL: str = "" + KEYCLOAK_INTERNAL_URL: str = "" + KEYCLOAK_ISSUER_URL: str = "" + CLIENT_SECRET: str = "" + + WEB_URL: str = "http://localhost:5173" + API_URL: str = "http://localhost:8000" # MongoDB MONGODB_URI: str = "mongodb://template_user:template_pass@mongo:27017/securelearning?authSource=securelearning" @@ -48,6 +57,7 @@ class Settings(BaseSettings): RABBITMQ_USER: str RABBITMQ_PASS: str RABBITMQ_QUEUE: str + RABBITMQ_TRACKING_QUEUE: str = "tracking_queue" # Statistics # Users who fell for phishing in more than this fraction of campaigns are diff --git a/api/src/main.py b/api/src/main.py index 403f3e2b..1af521b9 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -24,7 +24,7 @@ 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 @asynccontextmanager async def lifespan(app: FastAPI): @@ -34,11 +34,12 @@ async def lifespan(app: FastAPI): await ensure_bucket(settings.GARAGE_BUCKET_LOGOS) try: start_scheduler() + start_tracking_consumer() yield finally: + shutdown_scheduler() + shutdown_tracking_consumer() await close_mongo_client() - shutdown_scheduler() - app = FastAPI( title="Project Template API", @@ -49,8 +50,8 @@ async def lifespan(app: FastAPI): ) origins = [ - os.getenv("WEB_URL"), - os.getenv("API_URL"), + settings.WEB_URL, + settings.API_URL, ] app.add_middleware( diff --git a/api/src/models/email_sending/table.py b/api/src/models/email_sending/table.py index e90b9ee6..8c2bb25c 100644 --- a/api/src/models/email_sending/table.py +++ b/api/src/models/email_sending/table.py @@ -14,6 +14,7 @@ class EmailSendingStatus(StrEnum): SCHEDULED = "scheduled" + QUEUED = "queued" SENT = "sent" OPENED = "opened" CLICKED = "clicked" diff --git a/api/src/routers/tracking.py b/api/src/routers/tracking.py index 8b69bf60..22380c44 100644 --- a/api/src/routers/tracking.py +++ b/api/src/routers/tracking.py @@ -3,6 +3,7 @@ from src.core.dependencies import SessionDep, OAuth2Scheme from src.services.tracking import TrackingService +from src.core.settings import settings router = APIRouter() @@ -68,7 +69,7 @@ def track_sent(si: str, session: SessionDep): ) -@router.post( +@router.get( "/track/open", status_code=200, description="Tracking pixel endpoint - records email opens", @@ -100,13 +101,16 @@ async def track_click(si: str, session: SessionDep): @router.post( "/track/phish", - status_code=200, + status_code=303, description="Phishing event endpoint - records when user submits credentials on landing page", ) def track_phish(si: str, session: SessionDep): """ Called when user submits credentials on the landing page. - Records the phishing event. + Records the phishing event and redirects to the simulation oops page. """ service.record_phish(si, session) - return {"message": "Event recorded"} + return RedirectResponse( + url=f"{settings.WEB_URL}/simulation-oops.html", + status_code=303 + ) diff --git a/api/src/services/compliance/token_helpers.py b/api/src/services/compliance/token_helpers.py index 3ca9435d..ba0159ab 100644 --- a/api/src/services/compliance/token_helpers.py +++ b/api/src/services/compliance/token_helpers.py @@ -12,10 +12,11 @@ import jwt from jwt import PyJWKClient from fastapi import HTTPException, status +from src.core.settings import settings REALM_PATH = "/realms/" -AUTH_SERVER_URL = os.getenv("KEYCLOAK_INTERNAL_URL") or os.getenv("KEYCLOAK_URL") +AUTH_SERVER_URL = settings.KEYCLOAK_INTERNAL_URL or settings.KEYCLOAK_URL SYSTEM_REALMS = {"platform", "master"} PRIVILEGED_COMPLIANCE_ROLES = {"admin", "org_manager", "content_manager"} diff --git a/api/src/services/keycloak_admin/base_handler.py b/api/src/services/keycloak_admin/base_handler.py index 3f98f504..9b4db5c9 100644 --- a/api/src/services/keycloak_admin/base_handler.py +++ b/api/src/services/keycloak_admin/base_handler.py @@ -2,20 +2,17 @@ import json from pathlib import Path from fastapi import HTTPException -from dotenv import load_dotenv +from src.core.settings import settings from src.services.keycloak_client import get_keycloak_client -load_dotenv() - - class base_handler: def __init__(self): - self.keycloak_url = os.getenv("KEYCLOAK_URL") - self.admin_secret = os.getenv("CLIENT_SECRET") - self.web_url = os.getenv("WEB_URL", "http://localhost:3000") - self.api_url = os.getenv("API_URL", "http://localhost:8080") + self.keycloak_url = settings.KEYCLOAK_URL + self.admin_secret = settings.CLIENT_SECRET + self.web_url = settings.WEB_URL + self.api_url = settings.API_URL self.keycloak_client = get_keycloak_client() if not self.keycloak_url: diff --git a/api/src/services/keycloak_client/base_handler.py b/api/src/services/keycloak_client/base_handler.py index c9598b1f..83bc9b9a 100644 --- a/api/src/services/keycloak_client/base_handler.py +++ b/api/src/services/keycloak_client/base_handler.py @@ -2,17 +2,15 @@ import json import requests from fastapi import HTTPException -from dotenv import load_dotenv - -load_dotenv() +from src.core.settings import settings class base_handler: """Base handler with shared configuration and HTTP helpers.""" def __init__(self): - self.keycloak_url = os.getenv("KEYCLOAK_URL") - self.admin_secret = os.getenv("CLIENT_SECRET") + self.keycloak_url = settings.KEYCLOAK_URL + self.admin_secret = settings.CLIENT_SECRET if not self.keycloak_url: raise HTTPException( diff --git a/api/src/services/tracking.py b/api/src/services/tracking.py index 5c2d831d..f4810c0d 100644 --- a/api/src/services/tracking.py +++ b/api/src/services/tracking.py @@ -1,6 +1,6 @@ from datetime import datetime from fastapi import HTTPException -from sqlmodel import Session, select, text +from sqlmodel import Session, select, text, update from src.models import Campaign, EmailSending, EmailSendingStatus @@ -18,14 +18,16 @@ def record_sent(self, tracking_token: str, session: Session) -> EmailSending: if sending.sent_at is None: sending.sent_at = datetime.now() sending.status = EmailSendingStatus.SENT - - # Increment campaign counter - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_sent += 1 - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_sent=Campaign.total_sent + 1) + ) + session.commit() + session.refresh(sending) return sending @@ -37,14 +39,16 @@ def record_open(self, tracking_token: str, session: Session) -> EmailSending: if sending.opened_at is None: sending.opened_at = datetime.now() sending.status = EmailSendingStatus.OPENED - - # Increment campaign counter using ORM - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_opened += 1 - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_opened=Campaign.total_opened + 1) + ) + session.commit() + session.refresh(sending) return sending @@ -55,24 +59,28 @@ def record_click(self, tracking_token: str, session: Session) -> EmailSending: # Record open if not already recorded (click implies open) if sending.opened_at is None: sending.opened_at = datetime.now() - # Increment campaign counter using ORM - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_opened += 1 - session.add(campaign) - session.commit() + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_opened=Campaign.total_opened + 1) + ) # Only count first click if sending.clicked_at is None: sending.clicked_at = datetime.now() sending.status = EmailSendingStatus.CLICKED - # Increment campaign counter using ORM - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_clicked += 1 - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_clicked=Campaign.total_clicked + 1) + ) + + session.commit() + session.refresh(sending) return sending @@ -99,26 +107,36 @@ def record_phish(self, tracking_token: str, session: Session) -> EmailSending: sending = self._get_sending_by_token(tracking_token, session) # Record open and click if not already recorded (phish implies both) - campaign = session.get(Campaign, sending.campaign_id) if sending.opened_at is None: sending.opened_at = datetime.now() - if campaign: - campaign.total_opened += 1 + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_opened=Campaign.total_opened + 1) + ) + if sending.clicked_at is None: sending.clicked_at = datetime.now() - if campaign: - campaign.total_clicked += 1 + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_clicked=Campaign.total_clicked + 1) + ) # Only count first phish if sending.phished_at is None: sending.phished_at = datetime.now() sending.status = EmailSendingStatus.PHISHED - if campaign: - campaign.total_phished += 1 - if campaign: - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_phished=Campaign.total_phished + 1) + ) + + session.commit() + session.refresh(sending) return sending diff --git a/api/src/tasks/scheduler.py b/api/src/tasks/scheduler.py index 7bb0a2e0..d2ff0a41 100644 --- a/api/src/tasks/scheduler.py +++ b/api/src/tasks/scheduler.py @@ -174,12 +174,11 @@ def process_pending_emails() -> None: try: # Send email to RabbitMQ campaign_service._send_email_to_rabbitmq(email, campaign) - - # Update status and timestamp for sent emails - email.status = EmailSendingStatus.SENT - email.sent_at = datetime.now() - + + # 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 @@ -188,6 +187,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/src/tasks/tracking_consumer.py b/api/src/tasks/tracking_consumer.py new file mode 100644 index 00000000..d35577b4 --- /dev/null +++ b/api/src/tasks/tracking_consumer.py @@ -0,0 +1,94 @@ +import json +import logging +import threading +import time +import pika + +from sqlmodel import Session +from src.core.db import engine +from src.core.settings import settings +from src.services.tracking import TrackingService + +logger = logging.getLogger(__name__) + +_consumer_thread: threading.Thread | None = None +_stop_event = threading.Event() + +def process_tracking_message(channel, method, properties, body): + """Callback process for a single tracking message.""" + try: + data = json.loads(body) + if data.get("action") == "sent" and "tracking_id" in data: + tracking_id = data["tracking_id"] + + # Process in DB + with Session(engine) as session: + service = TrackingService() + service.record_sent(tracking_id, session) + + channel.basic_ack(method.delivery_tag) + except Exception as e: + logger.error(f"Error processing tracking message: {e}") + # Acknowledge to drop bad messages to prevent queue blocking + try: + channel.basic_ack(method.delivery_tag) + except Exception as ack_err: + logger.error(f"Failed to ack message after error: {ack_err}") + + +def _tracking_consumer_worker(): + """Worker function that continuously consumes tracking messages.""" + + while not _stop_event.is_set(): + try: + logger.info(f"Connecting to RabbitMQ tracking queue at {settings.RABBITMQ_HOST}...") + connection = pika.BlockingConnection(settings.RABBITMQ_CONNECTION_PARAMS) + channel = connection.channel() + channel.queue_declare(queue=settings.RABBITMQ_TRACKING_QUEUE, durable=True) + + # Don't consume too many unacked messages at once + channel.basic_qos(prefetch_count=10) + + for method, properties, body in channel.consume(settings.RABBITMQ_TRACKING_QUEUE, inactivity_timeout=1.0): + if _stop_event.is_set(): + break + + if method is None: + # Timeout reached + continue + + process_tracking_message(channel, method, properties, body) + + if connection.is_open: + connection.close() + + except Exception as e: + if not _stop_event.is_set(): + logger.error(f"RabbitMQ consumer connection error: {e}. Retrying in 5 seconds...") + time.sleep(5) + + +def start_tracking_consumer() -> None: + """Start the background tracking consumer thread.""" + global _consumer_thread + + if _consumer_thread is not None and _consumer_thread.is_alive(): + logger.warning("Tracking consumer is already running") + return + + _stop_event.clear() + _consumer_thread = threading.Thread(target=_tracking_consumer_worker, daemon=True, name="TrackingConsumer") + _consumer_thread.start() + logger.info("Started background RabbitMQ tracking consumer") + + +def shutdown_tracking_consumer() -> None: + """Signal the background tracking consumer to stop.""" + global _consumer_thread + + if _consumer_thread is not None: + _stop_event.set() + # We don't strictly need to join if it's daemon, but it's cleaner + _consumer_thread.join(timeout=2.0) + _consumer_thread = None + logger.info("Tracking consumer shutdown complete") diff --git a/api/test/tasks/test_scheduler.py b/api/test/tasks/test_scheduler.py index 4717a85e..6bf89404 100644 --- a/api/test/tasks/test_scheduler.py +++ b/api/test/tasks/test_scheduler.py @@ -108,7 +108,7 @@ def test_does_not_duplicate_sendings_on_second_run( class TestProcessPendingEmails: - def test_processes_pending_emails_and_marks_as_sent( + def test_processes_pending_emails_and_marks_as_queued( self, session: Session, users, @@ -133,6 +133,5 @@ def test_processes_pending_emails_and_marks_as_sent( ).all() assert len(sendings) == 3 - assert all(s.status == EmailSendingStatus.SENT for s in sendings) - assert all(s.sent_at is not None for s in sendings) + assert all(s.status == EmailSendingStatus.QUEUED for s in sendings) assert mock_rabbitmq_service.send_email.call_count == 3 diff --git a/api/test/test_tracking_consumer.py b/api/test/test_tracking_consumer.py new file mode 100644 index 00000000..1e249c6b --- /dev/null +++ b/api/test/test_tracking_consumer.py @@ -0,0 +1,95 @@ +import json +import pytest +from unittest.mock import patch, MagicMock +from sqlmodel import Session, create_engine +from sqlmodel.pool import StaticPool +from datetime import datetime +from uuid import uuid4 + +from src.models import User, Campaign, EmailSending, EmailSendingStatus +from src.core.db import engine +from src.tasks.tracking_consumer import process_tracking_message + +# Create an in-memory database for testing +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + +@pytest.fixture(name="session") +def session_fixture(): + from sqlmodel import SQLModel + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + SQLModel.metadata.drop_all(engine) + +def test_tracking_consumer_updates_database(session: Session): + # 0. Setup User + user = User(keycloak_id="u1", email="alice@test.com") + session.add(user) + session.commit() + + # 1. Setup Database Mock State + campaign = Campaign( + name="Test Consumer Campaign", + company_name="Corp", + total_sent=0, + begin_date=datetime.now(), + end_date=datetime.now() + ) + session.add(campaign) + session.commit() + session.refresh(campaign) + + email = EmailSending( + campaign_id=campaign.id, + user_id="u1", + first_name="Alice", + last_name="Test", + email_to="alice@test.com", + scheduled_date=datetime.now(), + tracking_token=str(uuid4()), + status=EmailSendingStatus.QUEUED # Simulating the scheduler's queuing state + ) + session.add(email) + session.commit() + session.refresh(email) + + campaign_id = campaign.id + email_id = email.id + + # 2. Setup RabbitMQ Mock Payload + payload = { + "action": "sent", + "tracking_id": email.tracking_token + } + + # 3. Mock the get_db context manager inside the consumer + # Because run_tracking_consumer creates its own session from the global engine, + # we patch it to yield our testing session instead. + with patch("src.tasks.tracking_consumer.Session", return_value=session): + # Simulate pika channel/method/properties, not used by the script extensively + mock_channel = MagicMock() + mock_method = MagicMock() + mock_method.delivery_tag = 1 + + # ACT: simulate a message consumption + process_tracking_message(mock_channel, mock_method, None, json.dumps(payload).encode('utf-8')) + + # Verify message was acknowledged + mock_channel.basic_ack.assert_called_once_with(1) + + # 4. Assert Database State was Mutated Correctly + # We refetch because the with session block in the consumer closed our mock session + email_db = session.get(EmailSending, email_id) + campaign_db = session.get(Campaign, campaign_id) + + # The consumer should have flipped it from QUEUED to SENT + assert email_db.status == EmailSendingStatus.SENT + assert email_db.sent_at is not None + assert type(email_db.sent_at) is datetime + + # And campaign counter must have incremented exactly once based on the event + assert campaign_db.total_sent == 1 diff --git a/api/test/test_tracking_endpoints.py b/api/test/test_tracking_endpoints.py new file mode 100644 index 00000000..cf20828c --- /dev/null +++ b/api/test/test_tracking_endpoints.py @@ -0,0 +1,126 @@ +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool +from datetime import datetime +import os + +from src.main import app +from src.core.dependencies import get_db +from src.models import Campaign, EmailSending, EmailSendingStatus +from src.core.settings import settings + +# Setup SQLite in-memory DB +@pytest.fixture(name="engine") +def engine_fixture(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +@pytest.fixture(name="session") +def session_fixture(engine): + with Session(engine) as session: + yield session + + +@pytest.fixture(name="client") +def client_fixture(session): + def get_session_override(): + return session + + app.dependency_overrides[get_db] = get_session_override + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture(name="mock_data") +def mock_data_fixture(session): + now = datetime.now() + c = Campaign( + name="Test Camp", + realm_name="test-realm", + total_recipients=1, + sending_interval_seconds=60, + begin_date=now, + end_date=now, + ) + session.add(c) + session.commit() + session.refresh(c) + + es = EmailSending( + campaign_id=c.id, + user_id="u1", + email_to="u@test.com", + scheduled_date=now, + tracking_token="valid-token-123", + ) + session.add(es) + session.commit() + return {"campaign": c, "email_sending": es} + + +def test_track_open_success(client: TestClient, mock_data: dict, session: Session): + response = client.get("/api/track/open?si=valid-token-123") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/gif" + + # Verify DB update + session.refresh(mock_data["email_sending"]) + assert mock_data["email_sending"].status == EmailSendingStatus.OPENED + assert mock_data["email_sending"].opened_at is not None + + session.refresh(mock_data["campaign"]) + assert mock_data["campaign"].total_opened == 1 + + +def test_track_phish_success_redirect(client: TestClient, mock_data: dict, session: Session): + response = client.post("/api/track/phish?si=valid-token-123", follow_redirects=False) + + # Should be 303 Redirect to frontend + assert response.status_code == 303 + assert response.headers["location"] == f"{settings.WEB_URL}/simulation-oops.html" + + # Verify DB update + session.refresh(mock_data["email_sending"]) + assert mock_data["email_sending"].status == EmailSendingStatus.PHISHED + assert mock_data["email_sending"].phished_at is not None + assert mock_data["email_sending"].opened_at is not None + + session.refresh(mock_data["campaign"]) + assert mock_data["campaign"].total_phished == 1 + assert mock_data["campaign"].total_opened == 1 + + +def test_track_invalid_token(client: TestClient): + response_open = client.get("/api/track/open?si=invalid-token") + assert response_open.status_code == 404 + + response_phish = client.post("/api/track/phish?si=invalid-token") + assert response_phish.status_code == 404 + +def test_simulate_rabbitmq_consumer(session: Session, mock_data: dict): + from src.services.tracking import TrackingService + import json + + # Simulate the payload the SMTP microservice publishes to AMQP + mock_rabbitmq_payload = json.dumps({"action": "sent", "tracking_id": "valid-token-123"}) + data = json.loads(mock_rabbitmq_payload) + + # Simulate consumer worker processing + if data.get("action") == "sent" and "tracking_id" in data: + service = TrackingService() + service.record_sent(data["tracking_id"], session) + + session.refresh(mock_data["email_sending"]) + assert mock_data["email_sending"].status == EmailSendingStatus.SENT + assert mock_data["email_sending"].sent_at is not None + + session.refresh(mock_data["campaign"]) + assert mock_data["campaign"].total_sent == 1 diff --git a/deployment/.env.dev.example b/deployment/.env.dev.example index 8200f6f0..249cc97c 100644 --- a/deployment/.env.dev.example +++ b/deployment/.env.dev.example @@ -54,3 +54,4 @@ KC_HOSTNAME_URL=http://localhost:8080 KEYCLOAK_URL=http://localhost:8080 API_URL=http://localhost:8000 WEB_URL=http://localhost:5173 +KEYCLOAK_ISSUER_URL=http://localhost:8080 diff --git a/deployment/.env.prod.example b/deployment/.env.prod.example index 45837ba4..84d89ca5 100644 --- a/deployment/.env.prod.example +++ b/deployment/.env.prod.example @@ -54,6 +54,7 @@ KC_HOSTNAME_URL=https://mednat.ieeta.pt:9071/kc KEYCLOAK_URL=https://mednat.ieeta.pt:9071/kc API_URL=https://mednat.ieeta.pt:9071/api WEB_URL=https://mednat.ieeta.pt:9071/app +KEYCLOAK_ISSUER_URL=https://mednat.ieeta.pt:9071/kc/realms/platform # Port Configuration NGINX_PORT=8081 diff --git a/deployment/docker-compose.dev.yml b/deployment/docker-compose.dev.yml index 74876b1b..64cb4674 100644 --- a/deployment/docker-compose.dev.yml +++ b/deployment/docker-compose.dev.yml @@ -16,7 +16,7 @@ services: healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -48,7 +48,7 @@ services: "--eval", "db.adminCommand('ping')" ] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -93,7 +93,7 @@ services: - rabbitmq_data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -139,6 +139,8 @@ services: WEB_URL: ${WEB_URL} API_URL: ${API_URL} JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING" + KC_DB_POOL_INITIAL_SIZE: 5 + KC_DB_POOL_MAX_SIZE: 10 healthcheck: test: @@ -233,8 +235,8 @@ services: ports: - "5173:5173" volumes: - - ../web/src:/app/src:ro - - ../web/public:/app/public:ro + - ../web:/app:rw + - /app/node_modules depends_on: api: condition: service_healthy diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c9e54e95..38c19cda 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -40,9 +40,9 @@ services: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s + interval: 5s timeout: 5s - retries: 10 + retries: 5 mongo: build: @@ -74,9 +74,9 @@ services: "--eval", "db.adminCommand('ping')" ] - interval: 10s + interval: 5s timeout: 5s - retries: 10 + retries: 5 garage: image: dxflrs/garage:76592723deb9285a071320c40842f6be61e924fd @@ -121,9 +121,9 @@ services: - rabbitmq_data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s + interval: 5s timeout: 5s - retries: 10 + retries: 5 smtp: build: @@ -174,6 +174,8 @@ services: WEB_URL: ${WEB_URL} API_URL: ${API_URL} JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING" + KC_DB_POOL_INITIAL_SIZE: 5 + KC_DB_POOL_MAX_SIZE: 10 healthcheck: test: ["CMD", "true"] interval: 30s @@ -202,7 +204,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} KEYCLOAK_URL: ${KEYCLOAK_INTERNAL_URL} - KEYCLOAK_ISSUER_URL: ${KEYCLOAK_URL} + KEYCLOAK_ISSUER_URL: ${KC_HOSTNAME_URL} CLIENT_SECRET: ${CLIENT_SECRET} MONGODB_URI: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/${MONGO_DB}?authSource=${MONGO_DB} MONGODB_DB: securelearning @@ -249,8 +251,8 @@ services: context: ../web dockerfile: Dockerfile args: - VITE_API_URL: ${API_URL} - VITE_KEYCLOAK_URL: ${KEYCLOAK_URL} + VITE_API_URL: ${API_URL}/api + VITE_KEYCLOAK_URL: ${KC_HOSTNAME_URL} VITE_BASE_PATH: ${VITE_BASE_PATH} VITE_WEB_URL: ${WEB_URL} container_name: frontend diff --git a/smtp/src/consumer.py b/smtp/src/consumer.py index 0c37521a..9d148713 100644 --- a/smtp/src/consumer.py +++ b/smtp/src/consumer.py @@ -48,6 +48,15 @@ def _handle_message( data = json.loads(body) email_message = EmailMessage(**data) self.email_sender.send(email_message) + + tracking_msg = json.dumps({"action": "sent", "tracking_id": email_message.tracking_id}) + if self._channel and self._channel.is_open: + self._channel.basic_publish( + exchange="", + routing_key=self.rabbitmq_config.RABBITMQ_TRACKING_QUEUE, + body=tracking_msg + ) + except json.JSONDecodeError: print("Error: Failed to decode JSON body") except ValidationError as e: diff --git a/smtp/src/core/config.py b/smtp/src/core/config.py index bf8759c7..a57cf0fd 100644 --- a/smtp/src/core/config.py +++ b/smtp/src/core/config.py @@ -16,6 +16,7 @@ class RabbitMQConfig(BaseSettings): RABBITMQ_USER: str RABBITMQ_PASS: str RABBITMQ_QUEUE: str + RABBITMQ_TRACKING_QUEUE: str = "tracking_queue" @property def credentials(self) -> pika.PlainCredentials: diff --git a/smtp/src/emails/email_sender.py b/smtp/src/emails/email_sender.py index e25c8980..23ea1bcb 100644 --- a/smtp/src/emails/email_sender.py +++ b/smtp/src/emails/email_sender.py @@ -49,22 +49,6 @@ def _send_via_smtp( print(f"Email sent successfully to {receiver}") - def _notify_tracking(self, tracking_id: str) -> None: - """Notify the API that an email was successfully sent.""" - config = APIConfig() - api_internal_url = config.API_INTERNAL_URL - try: - response = requests.post( - f"{api_internal_url}/api/track/sent?si={tracking_id}", - timeout=5 - ) - if response.ok: - print(f"Tracking recorded for {tracking_id}") - else: - print(f"Failed to record tracking: {response.status_code}") - except Exception as e: - print(f"Failed to notify tracking API: {e}") - def send(self, email_message: EmailMessage) -> None: """Process and send an email from an EmailMessage payload.""" @@ -104,7 +88,4 @@ def send(self, email_message: EmailMessage) -> None: sender=email_message.sender_email, receiver=email_message.receiver_email, message=message - ) - - # Notify tracking API that email was sent - self._notify_tracking(tracking_id) + ) \ No newline at end of file diff --git a/web/DESIGN_SYSTEM.md b/web/DESIGN_SYSTEM.md index c2fba759..78a49431 100644 --- a/web/DESIGN_SYSTEM.md +++ b/web/DESIGN_SYSTEM.md @@ -1,305 +1,120 @@ # SecureLearning — Design System -> Read this before adding any new page, component, or style. Goal: fully consistent UI across all modules and personas in light **and** dark mode. +> Read this before adding any new page, component, or style. Goal: fully consistent UI across all modules and personas in light, dark, and accessible modes. --- -## 1. Themes +## 1. Themes & Accessibility -Supports **Light**, **Dark**, **Deuteranopia**, and **Protanopia**. Toggled by adding the corresponding class (`.dark`, `.deuteranopia`, `.protanopia`) to ``. Uses the **View Transition API** clip-path wipe via `useThemeTransition`. +The application supports five core themes. The theme is changed by adding the corresponding class to the `` element. + +| Theme | Class | Purpose | Primary / Accent | +|---|---|---|---| +| **Light** | (default) | Standard bright mode | Purple (`#7C3AED`) | +| **Dark** | `.dark` | Low-light environment | Purple (`#7C3AED`) | +| **Deuteranopia** | `.deuteranopia` | Green-blindness | Cobalt Blue (`#005AB5`) | +| **Protanopia** | `.protanopia` | Red-blindness | Gold/Orange (`#E69F00`) | +| **Tritanopia** | `.tritanopia` | Blue-blindness | Crimson Red (`#D62828`) | + +### Theme Transitions +We use the **View Transition API** for fluid switching. The `useThemeTransition` hook performs a **center-out circular expansion** (500ms duration). ```ts const setTheme = useThemeTransition() -setTheme('dark') // 'light' | 'dark' | 'deuteranopia' | 'protanopia' | 'system' +setTheme('tritanopia') // 'light' | 'dark' | 'deuteranopia' | 'protanopia' | 'tritanopia' | 'system' ``` --- ## 2. Colour Tokens -All colours live in `src/globals.css` and are exposed to Tailwind via `@theme inline`. **Never hardcode hex values in JSX.** - -| Token | Light | Dark | Usage | -|---|---|---|---| -| `bg-background` | `#FFF` | `#0C0A0F` | Page root, overlays | -| `bg-surface` | `#FFF` | `#18151C` | Cards, tables, panels, modals | -| `bg-surface-subtle` | `#F9FAFB` | `#131019` | Table header rows, modal footers, sidebar | -| `bg-muted` | `#F3F4F6` | `#18151C` | View-toggle pill, tag backgrounds | -| `text-foreground` | `#111827` | `#EDEDED` | All primary labels | -| `text-muted-foreground` | `#6B7280` | `#A1A1AA` | Secondary labels, ``, placeholders | -| `border-border` | `#E5E7EB` | `rgba(167,139,250,.18)` | All borders and dividers | -| `bg-primary` / `text-primary` | `#7C3AED` | `#7C3AED` | Buttons, active states, avatar tint | -| `bg-primary/20` | tint | tint | Avatar bg, entity icon square bg, role badge | -| `hover:bg-primary/10` | tint | tint | Edit / navigate hover bg | -| `ring-primary/30` | ring | ring | Focus rings | - -### Destructive — `rose-500` only - -| Token | Usage | -|---|---| -| `text-rose-500` | Delete label / icon at rest | -| `bg-rose-500/10` | Delete button hover bg | -| `border-rose-500/30` | Delete button border (bordered variant) | +All colours live in `src/globals.css` and are exposed to Tailwind via `@theme inline`. **NEVER hardcode hex values in JSX.** -> ⚠️ Never use `text-red-*` or `bg-red-*`. +### Core Surfaces & Feedback ---- +| Token | Light Usage | Content | +|---|---|---| +| `--background` | Main page root | Standard app white / dark base | +| `--surface` | Cards, panels, modals | Elevated background | +| `--surface-subtle` | Table headers, secondary panels | Subtle differentiation | +| `--foreground` | Main text color | High contrast text | +| `--muted-foreground` | Secondary / helper text | De-emphasized text | +| `--primary` | Main action color | Follows theme (Purple/Blue/Gold/Red) | +| `--success` | Affirmative states | Safe colors per theme | +| `--warning` | Cautionary states | High visibility alerts | +| `--error` | Destructive / Error states | Critical feedback | +| `--border` | Dividers and outlines | Standard UI separation | -## 3. Typography +### Navigation Theme (Navbar & Sidebar) -All sizes use **bracket literals** for auditability. +The Top Bar (`Navbar`) and `Sidebar` are synchronized to provide a consistent frame. Always use these specific tokens for navigation elements: -| Class | Context | -|---|---| -| `text-base sm:text-lg lg:text-xl font-bold` | Header bar page title | -| `text-[15px] font-semibold` | Card primary name | -| `text-[14px] font-medium` | Table row primary text | -| `text-[14px]` | Table row secondary text | -| `text-[13px]` | Card secondary line | -| `text-[11px] font-semibold uppercase tracking-wider` | `` column headers | +- `bg-sidebar`: Background for both TopBar and Sidebar. +- `border-sidebar-border`: Border color for navigation dividers. +- `text-sidebar-foreground`: Standard text color for nav items. +- `bg-sidebar-accent`: Hover/Active background for nav items. +- `text-sidebar-accent-foreground`: Hover/Active text color for nav items. --- -## 4. Spacing & Radius +## 3. Brand Consistency (Untouchables) -| Context | Value | -|---|---| -| Header bar (horizontal) | `px-3 sm:px-4 lg:px-6` | -| Table `` / `` | `px-6 py-4` | -| Card body | `p-5` | -| Modal header / footer | `px-5 py-4` | -| Grid wrapper | `p-6` | +While most UI elements adapt to the active theme, the **Brand Identity** must remain consistent to preserve company image. -| Class | When | -|---|---| -| `rounded-md` | Inputs, buttons, toggle pill, icon squares, context menus | -| `rounded-lg` | Cards | -| `rounded-xl` | Modals / dialogs | -| `rounded-full` | User avatar (circle) | +- **Logo Text ("Learning")**: Always hardcoded to `#7C3AED`. +- **Loading Screens**: Use the standard brand palette. +- **Brand Imagery**: Hat logo and primary marketing assets do not transform. --- -## 5. Layout +## 4. Typography & Spacing -### Page shell -```tsx -
- {/* owns border-b — never add another border-b wrapper */} -
- {/* content */} -
-
-``` +### Font Sizes +Use standard Tailwind classes with responsive modifiers for auditability. +- **Page Titles**: `text-2xl font-bold` +- **Section Headers**: `text-lg font-semibold` +- **Body Text**: `text-[14px] sm:text-base` +- **Labels**: `text-sm font-medium` -### Header bar -``` -h-16 lg:h-20 w-full flex items-center -px-3 sm:px-4 lg:px-6 gap-2 sm:gap-4 -border-b border-border flex-shrink-0 -``` -Left: title (`flex-shrink-0`). Right: `flex-1 flex items-center justify-end gap-2 sm:gap-3` → search → view toggle → primary CTA. - -### Table container -``` -bg-surface border border-border rounded-lg overflow-hidden -``` -The inner `` has **no** extra border, shadow, or rounding. -Header row: `bg-surface-subtle/80 border-b border-border/60` -Body row: `hover:bg-surface-subtle/60 transition-colors border-b border-border/60 last:border-0` - -### Card container -``` -bg-surface border border-border rounded-lg -hover:border-primary/30 hover:shadow-sm transition-all duration-200 -``` -No gradient bar. No gradient avatar. No per-entity colour prop. - -### Card grid -``` -grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 -``` +### Built-in Spacing +- **Container Padding**: `px-3 sm:px-4 lg:px-6` +- **Grid Gap**: `gap-4` +- **Card Padding**: `p-4` or `p-5` --- -## 6. Components +## 5. Components & Interactive Patterns ### ThemeCard — `src/components/shared/ThemeCard.tsx` -Used in the Appearance settings to display theme options. It accepts an `accentColor` prop to highlight the active state using the theme's own accent color. - -```tsx - -``` +Displays a theme option with a specific icon and accent color. +- **Icon Borders**: Icons should have a `border border-border/50` for structure. +- **Interactive**: Uses `transition-all duration-300` for fluid hover states. -### UserAvatar — `src/components/shared/UserAvatar.tsx` -```tsx - // circle md (w-10 h-10) - // table row - // card header -``` -Always `bg-primary/20 text-primary`. Never a gradient. - -### Entity icon square (groups, profiles — non-person entities) -```tsx -// Table row (sm) -
- -
-// Card (md) -
- -
-``` - -### View toggle -```tsx -
- {[{ id: "table", Icon: List }, { id: "grid", Icon: LayoutGrid }].map(({ id, Icon }) => ( - - ))} -
-``` -Icons: **`List`** (table) then **`LayoutGrid`** (grid) — always this order. Both from `lucide-react`. - -### Buttons -```tsx -// Primary -
` | Outer container only | -| `bg-surface-subtle min-h-screen` on grid wrapper | Transparent, no bg | -| Extra `border-b` div wrapping the header | Header owns its own `border-b` | -| Nested ternaries in JSX render | Early-return guards | -| `Grid3x3` / `TableProperties` for view toggle | `List` / `LayoutGrid` | -| `min-h-screen` on components | `h-full` / `flex-1` | -| Hardcoded hex in JSX | CSS token via Tailwind class | -| Portuguese comments | English or no comments | +| Hardcode hex values (`#7C3AED`) | Use theme variables (`var(--primary)`) | +| Use Tailwind color literals (`text-purple-600`) | Use functional tokens (`text-primary`) | +| Use `window.confirm` | Use `ConfirmProvider` / custom modals | +| Ignore accessibility modes | Test each change in Deuteranopia/Protanopia/Tritanopia | +| Mix `background` and `sidebar` colors in TopBar | Use `bg-sidebar` for both TopBar and Sidebar | +| Add `border-b` to content if Header has it | Header owns the border; content flows beneath | --- -## 8. Adding a New Page - -Steps: create route in `src/routes/` → register in `src/routeTree.gen.ts` (4 places) → add sidebar link in `src/components/sidebar.tsx` → follow page shell (§ 5). +## 7. New Page Checklist -### New component checklist -- [ ] `bg-background` / `bg-surface` on outer wrapper -- [ ] `text-foreground` / `text-muted-foreground` for text -- [ ] `border-border` on all borders -- [ ] `bg-primary` + `hover:bg-primary/90` for primary actions -- [ ] `rose-500` tokens for destructive only -- [ ] `focus:ring-primary/30` on all inputs -- [ ] No `window.confirm` — use `ConfirmDeleteModal` -- [ ] Empty states follow early-return pattern (§ 6) -- [ ] View toggle: `List` → `LayoutGrid`, table first -- [ ] No raw `purple-*` `gray-*` `slate-*` `red-*` classes -- [ ] Tested in light **and** dark mode - ---- - -## 9. Key Files - -| File | Purpose | -|---|---| -| `src/globals.css` | CSS custom properties, Tailwind `@theme` mapping, scrollbar, view-transition rules | -| `src/components/shared/UserAvatar.tsx` | Shared person avatar — use everywhere | -| `src/components/usergroups/ConfirmDeleteModal.tsx` | Generic delete confirmation — reuse for any entity | -| `src/lib/useThemeTransition.ts` | Animated theme switching (View Transition API) | -| `src/components/SettingsPanel.tsx` | Shared settings UI for all 3 persona settings routes | -| `src/components/sidebar.tsx` | Sidebar with per-persona nav groups | -| `src/routeTree.gen.ts` | Auto-generated route tree (manually maintained when dev server is off) | +- [ ] Page shell uses `h-full w-full flex flex-col`. +- [ ] Header uses `bg-sidebar border-sidebar-border`. +- [ ] Content area uses theme variables for all surfaces. +- [ ] All interactive elements (buttons, inputs) have theme-aware hover/focus states. +- [ ] No hardcoded colors (except specific brand assets). +- [ ] Verified in **Light, Dark, and all 3 Accessibility modes**. diff --git a/web/Dockerfile b/web/Dockerfile index 9e158e5d..f504dd46 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -15,12 +15,7 @@ ENV VITE_BASE_PATH=$VITE_BASE_PATH ENV VITE_WEB_URL=$VITE_WEB_URL COPY package*.json ./ -RUN npm config set registry https://registry.npmjs.org/ \ - && npm config set fetch-retries 5 \ - && npm config set fetch-retry-factor 2 \ - && npm config set fetch-retry-mintimeout 20000 \ - && npm config set fetch-retry-maxtimeout 120000 \ - && npm ci --no-audit --no-fund +RUN npm ci --no-audit --no-fund COPY . . RUN npm run build diff --git a/web/simulation-oops.html b/web/simulation-oops.html new file mode 100644 index 00000000..5ba2b822 --- /dev/null +++ b/web/simulation-oops.html @@ -0,0 +1,14 @@ + + + + + + + + SecureLearning | Simulation Oops + + +
+ + + diff --git a/web/src/Pages/campaign-details.tsx b/web/src/Pages/campaign-details.tsx index 9659c851..8c719c53 100644 --- a/web/src/Pages/campaign-details.tsx +++ b/web/src/Pages/campaign-details.tsx @@ -109,10 +109,10 @@ export default function CampaignDetails() {
-
{isEditable ? : } @@ -133,7 +133,7 @@ export default function CampaignDetails() {
{loadError && ( -
+
{loadError}
@@ -141,7 +141,7 @@ export default function CampaignDetails() { {!isEditable && (
-
+

Editing is disabled

diff --git a/web/src/Pages/campaign-edit.tsx b/web/src/Pages/campaign-edit.tsx index 4a22cc9e..f69ed980 100644 --- a/web/src/Pages/campaign-edit.tsx +++ b/web/src/Pages/campaign-edit.tsx @@ -169,7 +169,7 @@ export default function CampaignEditPage() { ) : (
-
+

Editing is disabled

diff --git a/web/src/Pages/user-details.tsx b/web/src/Pages/user-details.tsx index 6da3aace..9f026014 100644 --- a/web/src/Pages/user-details.tsx +++ b/web/src/Pages/user-details.tsx @@ -8,14 +8,7 @@ import { import { Link, useParams, useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; import { useKeycloak } from "@react-keycloak/web"; -import { - fetchGroups, - fetchGroupMembers, - addUserToGroup, - fetchUsers, - removeUserFromGroup, - deleteGroup, -} from "@/services/userGroupsApi"; +import { userGroupsApi } from "@/services/userGroupsApi"; import ConfirmDeleteModal from "@/components/usergroups/ConfirmDeleteModal"; import { GroupMembersTable } from "@/components/usergroups/GroupMembersTable"; import SearchBar from "@/components/shared/SearchBar"; @@ -61,8 +54,8 @@ export default function UserGroupDetail() { setIsLoading(true); try { const [groupsRes, membersRes] = await Promise.all([ - fetchGroups(realm, keycloak.token || undefined), - fetchGroupMembers(realm, groupId, keycloak.token || undefined), + userGroupsApi.getGroups(realm), + userGroupsApi.getGroupMembers(realm, groupId), ]); const found = (groupsRes.groups || []).find((g) => g.id === groupId); setGroupName(found?.name || groupId); @@ -81,7 +74,7 @@ export default function UserGroupDetail() { const loadUsers = async () => { if (!realm) return; try { - const res = await fetchUsers(realm, keycloak.token || undefined); + const res = await userGroupsApi.getUsers(realm); setUsers( (res.users || []).map((u) => ({ id: u.id || "", @@ -102,7 +95,7 @@ export default function UserGroupDetail() { if (!realm || !groupId) return; setIsDeleting(true); try { - await deleteGroup(realm, groupId, keycloak.token || undefined); + await userGroupsApi.deleteGroup(realm, groupId); navigate({ to: "/usergroups" }); } catch (err) { console.error("Failed to delete group", err); @@ -118,8 +111,8 @@ export default function UserGroupDetail() { setIsLoading(true); setStatus(null); try { - await removeUserFromGroup(realm, groupId, memberId, keycloak.token || undefined); - const membersRes = await fetchGroupMembers(realm, groupId, keycloak.token || undefined); + await userGroupsApi.removeUserFromGroup(realm, groupId, memberId); + const membersRes = await userGroupsApi.getGroupMembers(realm, groupId); setMembers(membersRes.members || []); } catch (err) { console.error("Failed to remove member", err); @@ -234,8 +227,8 @@ export default function UserGroupDetail() { setIsLoading(true); setStatus(null); try { - await addUserToGroup(realm, groupId, u.id, keycloak.token || undefined); - const membersRes = await fetchGroupMembers(realm, groupId, keycloak.token || undefined); + await userGroupsApi.addUserToGroup(realm, groupId, u.id); + const membersRes = await userGroupsApi.getGroupMembers(realm, groupId); setMembers(membersRes.members || []); setShowAddModal(false); setSearchUser(""); @@ -280,4 +273,3 @@ export default function UserGroupDetail() { ); } - diff --git a/web/src/Pages/user-groups.tsx b/web/src/Pages/user-groups.tsx index 4d5dfe4d..bc65f8be 100644 --- a/web/src/Pages/user-groups.tsx +++ b/web/src/Pages/user-groups.tsx @@ -4,11 +4,7 @@ import UserGroupsHeader from "@/components/usergroups/userGroupsHeader"; import UserGroupsGrid from "@/components/usergroups/userGroupsGrid"; import UserGroupsTable from "@/components/usergroups/userGroupsTable"; import ConfirmDeleteModal from "@/components/usergroups/ConfirmDeleteModal"; -import { - fetchGroups, - fetchGroupMembers, - deleteGroup, -} from "@/services/userGroupsApi"; +import { userGroupsApi } from "@/services/userGroupsApi"; import { Loader2 } from "lucide-react"; export default function UserGroupsPage() { @@ -33,20 +29,13 @@ export default function UserGroupsPage() { const loadData = async (targetRealm: string) => { setIsLoading(true); try { - const groupsRes = await fetchGroups( - targetRealm, - keycloak.token || undefined - ); + const groupsRes = await userGroupsApi.getGroups(targetRealm); // Optionally fetch member counts to keep cards/table closer to previous UX const groupsWithCounts = await Promise.all( (groupsRes.groups || []).map(async (g) => { let memberCount = 0; try { - const membersRes = await fetchGroupMembers( - targetRealm, - g.id || "", - keycloak.token || undefined - ); + const membersRes = await userGroupsApi.getGroupMembers(targetRealm, g.id || ""); memberCount = (membersRes.members || []).length; } catch { memberCount = 0; @@ -75,7 +64,7 @@ export default function UserGroupsPage() { if (!pendingDeleteId || !realm) return; setIsDeleting(true); try { - await deleteGroup(realm, pendingDeleteId, keycloak.token || undefined); + await userGroupsApi.deleteGroup(realm, pendingDeleteId); setPendingDeleteId(null); loadData(realm); } catch (err) { diff --git a/web/src/components/AdminPanel.tsx b/web/src/components/AdminPanel.tsx index 442b5c0d..8a2846ea 100644 --- a/web/src/components/AdminPanel.tsx +++ b/web/src/components/AdminPanel.tsx @@ -121,7 +121,7 @@ export function CreateTenantPage() { } return ( -
+
{/* Form section */}
diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index 3ff4f4aa..9af164ef 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -74,15 +74,18 @@ export function AppLoader({ />
Secure - Learning + Learning
{/* Progress bar */}
diff --git a/web/src/components/EmailEntry.tsx b/web/src/components/EmailEntry.tsx index 43c8351d..6dcf1eb1 100644 --- a/web/src/components/EmailEntry.tsx +++ b/web/src/components/EmailEntry.tsx @@ -68,7 +68,7 @@ export const EmailEntry = () => { className="max-w-md mx-auto" >
-
+
diff --git a/web/src/components/SettingsPanel.tsx b/web/src/components/SettingsPanel.tsx index 73f802d0..45759ecd 100644 --- a/web/src/components/SettingsPanel.tsx +++ b/web/src/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useTheme } from 'next-themes' -import { Sun, Moon, Monitor, Eye, Aperture } from 'lucide-react' +import { Sun, Moon, Monitor, EyeOff } from 'lucide-react' import { useEffect, useState } from 'react' import { useThemeTransition } from '@/lib/useThemeTransition' import { ThemeCard } from '@/components/shared/ThemeCard' @@ -14,35 +14,42 @@ const themeOptions: ThemeOption[] = [ value: 'light', label: 'Light', icon: Sun, - accentColor: '#7C3AED', + accentColor: 'var(--theme-light-primary)', description: 'Clean white background, optimised for bright environments.', }, { value: 'dark', label: 'Dark', icon: Moon, - accentColor: '#7C3AED', + accentColor: 'var(--theme-dark-primary)', description: 'Dark background, easier on the eyes in low-light settings.', }, { value: 'deuteranopia', label: 'Deuteranopia', - icon: Eye, - accentColor: '#1D6FE8', + icon: EyeOff, + accentColor: 'var(--theme-deuteranopia-primary)', description: 'Blue accent palette — optimised for green-blind users.', }, { value: 'protanopia', label: 'Protanopia', - icon: Aperture, - accentColor: '#0891B2', + icon: EyeOff, + accentColor: 'var(--theme-protanopia-primary)', description: 'Teal accent palette — optimised for red-blind users.', }, + { + value: 'tritanopia', + label: 'Tritanopia', + icon: EyeOff, + accentColor: 'var(--theme-tritanopia-primary)', + description: 'Red/Cyan accent palette — optimised for blue-blind users.', + }, { value: 'system', label: 'System', icon: Monitor, - accentColor: '#7C3AED', + accentColor: 'var(--theme-light-primary)', description: 'Automatically follows your operating-system preference.', }, ] @@ -77,7 +84,7 @@ export function SettingsPanel() {
) : (
- {[0, 1, 2, 3, 4].map((i) => ( + {[0, 1, 2, 3, 4, 5].map((i) => (
))}
diff --git a/web/src/components/SimulationOops.tsx b/web/src/components/SimulationOops.tsx new file mode 100644 index 00000000..02a176f3 --- /dev/null +++ b/web/src/components/SimulationOops.tsx @@ -0,0 +1,74 @@ +import { Eye, AlertTriangle, Link as LinkIcon, ShieldCheck } from 'lucide-react' + +export function SimulationOops() { + return ( +
+
+ + {/* Simplified Header */} +
+
+ +
+

+ Wait! That was a simulation. +

+

+ You just fell for a simulated phishing attack. Don't worry, your account is safe. This is how we help you stay sharp! +

+
+ + {/* Skimmable Red Flags */} +
+

+ 3 Red Flags to Watch For: +

+ +
+ {/* Flag 1 */} +
+
+ +
+
+

Check the Sender

+

Always verify the actual email address, not just the display name.

+
+
+ + {/* Flag 2 */} +
+
+ +
+
+

Hover Before Clicking

+

Hover over links to see their real destination before you click.

+
+
+ + {/* Flag 3 */} +
+
+ +
+
+

Spot the Urgency

+

Be wary of "immediate action" requests that create fear or panic.

+
+
+
+
+ + {/* Footer */} +
+ Learning keeps us safe. Treat this as a learning opportunity. +
+
+ +
+ © SecureLearning {new Date().getFullYear()}. All rights reserved. +
+
+ ) +} diff --git a/web/src/components/WelcomePage.css b/web/src/components/WelcomePage.css index 6ab67ef2..cfd2c7ac 100644 --- a/web/src/components/WelcomePage.css +++ b/web/src/components/WelcomePage.css @@ -10,12 +10,12 @@ } .quick-card-container:hover { - box-shadow: 0 10px 25px rgba(124, 58, 237, 0.15), 0 4px 10px rgba(0, 0, 0, 0.08); + box-shadow: 0 10px 25px rgba(var(--primary-rgb), 0.15), 0 4px 10px rgba(0, 0, 0, 0.08); border-color: var(--accent-secondary); } -.dark .quick-card-container:hover { - box-shadow: 0 10px 25px rgba(124, 58, 237, 0.25), 0 4px 10px rgba(0, 0, 0, 0.3); +.dark .quick-card-container:active { + box-shadow: 0 10px 25px rgba(var(--primary-rgb), 0.25), 0 4px 10px rgba(0, 0, 0, 0.3); } /* ── Front face (always visible) ── */ diff --git a/web/src/components/admin/LogViewer.tsx b/web/src/components/admin/LogViewer.tsx index 3fe6c74f..9afdc908 100644 --- a/web/src/components/admin/LogViewer.tsx +++ b/web/src/components/admin/LogViewer.tsx @@ -53,13 +53,13 @@ export function LogViewer() { const getIcon = (level: LogEntry["level"]) => { switch (level) { case "error": - return ; + return ; case "warning": - return ; + return ; case "success": - return ; + return ; default: - return ; + return ; } }; @@ -93,7 +93,7 @@ export function LogViewer() {
{error && ( -
+

Failed to load logs

{error}

@@ -111,11 +111,11 @@ export function LogViewer() { placeholder="Search logs..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="w-full pl-9 pr-4 py-1.5 text-sm border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full pl-9 pr-4 py-1.5 text-sm border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50" />
setSelectedGroup(e.target.value)} > @@ -88,14 +88,14 @@ export function UserList() { if (user.role === 'Admin') { roleBadgeClass = 'bg-primary/20 text-primary/80' } else if (user.role === 'Manager') { - roleBadgeClass = 'bg-blue-100 text-blue-800' + roleBadgeClass = 'bg-info/10 text-info' } return (
` + `` ).join('') const bodyRows = rows.trim().split('\n').map((row: string) => { const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) => - `` + `` ).join('') return `${tds}` }).join('') @@ -141,25 +141,25 @@ export function renderMarkdown(md: string): string { // Headings out = out - .replaceAll(/^### (.+)$/gm, '

$1

') - .replaceAll(/^## (.+)$/gm, '

$1

') - .replaceAll(/^# (.+)$/gm, '

$1

') + .replaceAll(/^### (.+)$/gm, '

$1

') + .replaceAll(/^## (.+)$/gm, '

$1

') + .replaceAll(/^# (.+)$/gm, '

$1

') // Inline: bold, italic, strikethrough, inline-code out = out - .replaceAll(/\*\*(.+?)\*\*/g, '$1') - .replaceAll(/~~(.+?)~~/g, '$1') + .replaceAll(/\*\*(.+?)\*\*/g, '$1') + .replaceAll(/~~(.+?)~~/g, '$1') .replaceAll(/_(.+?)_/g, '$1') - .replaceAll(/`([^`]+)`/g, '$1') + .replaceAll(/`([^`]+)`/g, '$1') // Lists out = out - .replaceAll(/^- (.+)$/gm, '
  • $1
  • ') - .replaceAll(/^\d+\. (.+)$/gm, '
  • $1
  • ') + .replaceAll(/^- (.+)$/gm, '
  • $1
  • ') + .replaceAll(/^\d+\. (.+)$/gm, '
  • $1
  • ') // Plain paragraphs (lines not already wrapped in an HTML tag) out = out.replaceAll(/^(?!<[a-zA-Z/])(.+)$/gm, - '

    $1

    ') + '

    $1

    ') // Collapse multiple blank lines out = out.replaceAll(/\n{2,}/g, '\n') diff --git a/web/src/components/courses/CourseCard.tsx b/web/src/components/courses/CourseCard.tsx index e4db938a..f8a32daa 100644 --- a/web/src/components/courses/CourseCard.tsx +++ b/web/src/components/courses/CourseCard.tsx @@ -55,7 +55,7 @@ type CourseCardProps = { function ProgressBadge({ progress }: { progress: number }) { if (progress === 100) - return ✓ Completed + return ✓ Completed if (progress > 0) return {progress}% return null @@ -83,7 +83,7 @@ function DeleteButton({ id, onDelete }: { id: string; onDelete?: (id: string) => e.preventDefault() onDelete(id) }} - className="p-1.5 rounded-lg bg-surface/80 backdrop-blur-md border border-border/50 text-muted-foreground hover:text-red-500 hover:bg-red-50 hover:border-red-200 transition-all shadow-sm" + className="p-1.5 rounded-lg bg-surface/80 backdrop-blur-md border border-border/50 text-muted-foreground hover:text-error hover:bg-error/10 hover:border-error/20 transition-all shadow-sm" title="Delete Module" > @@ -148,10 +148,10 @@ function Banner({ } return ( -
    +
    {item.icon ? {item.icon} - : + : } {showProg && (item.progress ?? 0) > 0 && (
    @@ -160,7 +160,7 @@ function Banner({ )} {item.difficultyBadge && (
    - + {item.difficultyBadge}
    @@ -185,10 +185,10 @@ function CardHorizontal({ item, to, params }: { item: CardItem; to: string; para {item.title}
    ) : ( -
    +
    {item.icon ? {item.icon} - : + : } {showProg && (item.progress ?? 0) > 0 && (
    diff --git a/web/src/components/courses/CourseHeader.tsx b/web/src/components/courses/CourseHeader.tsx index dff7aa7f..c92d14ce 100644 --- a/web/src/components/courses/CourseHeader.tsx +++ b/web/src/components/courses/CourseHeader.tsx @@ -94,7 +94,7 @@ export default function CourseHeader({
    diff --git a/web/src/components/courses/ModuleLearner.tsx b/web/src/components/courses/ModuleLearner.tsx index c025db9d..15e3d758 100644 --- a/web/src/components/courses/ModuleLearner.tsx +++ b/web/src/components/courses/ModuleLearner.tsx @@ -113,18 +113,18 @@ function QuestionBlock({ const choiceBtnClass = (c: Choice) => { if (answered !== c.id) { - return "bg-background border-border text-foreground/90 hover:bg-primary/10/50 hover:border-primary/30"; + return "bg-background border-border text-foreground/90 hover:bg-primary/5 hover:border-primary/30"; } return c.isCorrect - ? "bg-green-50 border-green-400 text-green-800" - : "bg-red-50 border-red-400 text-red-800"; + ? "bg-success/10 border-success/40 text-success" + : "bg-error/10 border-error/40 text-error"; }; const choiceCircleClass = (c: Choice) => { if (answered !== c.id) return "border-border/60"; return c.isCorrect - ? "border-green-500 bg-green-100" - : "border-red-500 bg-red-100"; + ? "border-success bg-success/20" + : "border-error bg-error/20"; }; return ( @@ -175,10 +175,10 @@ function QuestionBlock({ className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 ${choiceCircleClass(c)}`} > {isSelected && c.isCorrect && ( - + )} {isSelected && !c.isCorrect && ( - + )} @@ -210,7 +210,7 @@ function PreviewBlock({ if (block.kind === "text") { return (
    { setTimeout(onDone, 2200); }} - className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-background text-white px-5 py-3 rounded-xl shadow-2xl text-sm font-medium flex items-center gap-2" + className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-background text-foreground px-5 py-3 rounded-xl shadow-2xl text-sm font-medium flex items-center gap-2 border border-border" > - + {message} ); @@ -604,12 +604,12 @@ export default function ModuleLearner({ module: mod, courseId }: Props) {

    {sec.isOptional && ( - + Optional )} {sec.requireCorrectAnswers && ( - + Required )} @@ -673,7 +673,7 @@ export default function ModuleLearner({ module: mod, courseId }: Props) { isLocked ? "border-border opacity-60" : isComplete - ? "border-emerald-200 bg-emerald-50/30" + ? "border-success/20 bg-success/5" : "border-border" }`} > @@ -690,7 +690,7 @@ export default function ModuleLearner({ module: mod, courseId }: Props) { > {isComplete ? ( @@ -703,18 +703,18 @@ export default function ModuleLearner({ module: mod, courseId }: Props) {

    {sec.title || `Section ${i + 1}`}

    {sec.isOptional && ( - + Optional )} {sec.requireCorrectAnswers && ( - + Correct answers required @@ -726,7 +726,7 @@ export default function ModuleLearner({ module: mod, courseId }: Props) { )} {isComplete && ( - + ✓ Completed )} @@ -810,7 +810,7 @@ export default function ModuleLearner({ module: mod, courseId }: Props) {
    @@ -58,7 +58,7 @@ export function Navbar() { ); return ( -
    -
    +
    {user.name.charAt(0)}
    @@ -111,9 +111,9 @@ export function UserList() {
    {user.group} - - {user.status.charAt(0).toUpperCase() + user.status.slice(1)} @@ -121,10 +121,10 @@ export function UserList() { {user.lastActive}
    - -
    diff --git a/web/src/components/admin/tenant-org-manager/BulkImportModal.tsx b/web/src/components/admin/tenant-org-manager/BulkImportModal.tsx index 844b9595..b7b6fb90 100644 --- a/web/src/components/admin/tenant-org-manager/BulkImportModal.tsx +++ b/web/src/components/admin/tenant-org-manager/BulkImportModal.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { X, Loader2 } from "lucide-react"; -import { useKeycloak } from "@react-keycloak/web"; import type { BulkUser } from "./types"; +import { tenantOrgManagerApi } from "@/services/tenantOrgManagerApi"; +import { userGroupsApi } from "@/services/userGroupsApi"; interface BulkImportModalProps { realm: string; @@ -10,15 +11,12 @@ interface BulkImportModalProps { onBulkCreated: () => void; } -const API_BASE = import.meta.env.VITE_API_URL; - export function BulkImportModal({ realm, initialBulkUsers, onClose, onBulkCreated, }: BulkImportModalProps) { - const { keycloak } = useKeycloak(); const [bulkUsers, setBulkUsers] = useState(initialBulkUsers); const [isBulkLoading, setIsBulkLoading] = useState(false); @@ -38,11 +36,7 @@ export function BulkImportModal({ const groupIdMap: Record = {}; const fetchGroupsIds = async () => { try { - const res = await fetch(`${API_BASE}/realms/${encodeURIComponent(realm)}/groups`, { - headers: { Authorization: keycloak.token ? `Bearer ${keycloak.token}` : "" }, - }); - if (!res.ok) return; - const data = await res.json(); + const data = await userGroupsApi.getGroups(realm); (data.groups || []).forEach((g: any) => { if (g.name && g.id) groupIdMap[g.name] = g.id; }); @@ -54,15 +48,8 @@ export function BulkImportModal({ for (const name of groupNames) { if (groupIdMap[name]) continue; try { - const res = await fetch(`${API_BASE}/realms/${encodeURIComponent(realm)}/groups`, { - method: "POST", - headers: { - Authorization: keycloak.token ? `Bearer ${keycloak.token}` : "", - "Content-Type": "application/json", - }, - body: JSON.stringify({ name }), - }); - if (res.ok) await fetchGroupsIds(); + await userGroupsApi.createGroup(realm, { name }); + await fetchGroupsIds(); } catch { /* ignore */ } } @@ -76,55 +63,39 @@ export function BulkImportModal({ continue; } try { - const res = await fetch(`${API_BASE}/realms/${encodeURIComponent(realm)}/users`, { - method: "POST", - headers: { - Authorization: keycloak.token ? `Bearer ${keycloak.token}` : "", - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await tenantOrgManagerApi.createUser( + realm, + { username: u.username, name: u.name, email: u.email, role: u.role, - }), - }); - if (!res.ok) { - updated[i] = { ...u, status: `error ${res.status}` }; - continue; - } - const data = await res.json(); + } + ); updated[i] = { ...u, status: `created (pwd: ${data?.temporary_password ?? "N/A"})` }; if (u.groups?.length) createdUsers.push({ email: u.email, groups: u.groups }); - } catch { - updated[i] = { ...u, status: "error" }; + } catch (error) { + const message = error instanceof Error ? error.message : "error"; + updated[i] = { ...u, status: message }; } } // Assign groups try { - const res = await fetch(`${API_BASE}/realms/${encodeURIComponent(realm)}/users`, { - headers: { Authorization: keycloak.token ? `Bearer ${keycloak.token}` : "" }, + const data = await tenantOrgManagerApi.getUsers(realm); + const userMap: Record = {}; + (data.users || []).forEach((usr: any) => { + if (usr.email && usr.id) userMap[usr.email.toLowerCase()] = usr.id; }); - if (res.ok) { - const data = await res.json(); - const userMap: Record = {}; - (data.users || []).forEach((usr: any) => { - if (usr.email && usr.id) userMap[usr.email.toLowerCase()] = usr.id; - }); - for (const entry of createdUsers) { - const uid = userMap[entry.email.toLowerCase()]; - if (!uid) continue; - for (const gName of entry.groups) { - const gid = groupIdMap[gName]; - if (!gid) continue; - try { - await fetch(`${API_BASE}/realms/${encodeURIComponent(realm)}/groups/${encodeURIComponent(gid)}/members/${encodeURIComponent(uid)}`, { - method: "POST", - headers: { Authorization: keycloak.token ? `Bearer ${keycloak.token}` : "" }, - }); - } catch { /* ignore */ } - } + for (const entry of createdUsers) { + const uid = userMap[entry.email.toLowerCase()]; + if (!uid) continue; + for (const gName of entry.groups) { + const gid = groupIdMap[gName]; + if (!gid) continue; + try { + await userGroupsApi.addUserToGroup(realm, gid, uid); + } catch { /* ignore */ } } } } catch { /* ignore */ } @@ -191,7 +162,7 @@ export function BulkImportModal({
    - + {campaign.stats.clicked} diff --git a/web/src/components/campaigns/new-campaign/CampaignSummary.tsx b/web/src/components/campaigns/new-campaign/CampaignSummary.tsx index 6b0bbd7d..40d974ff 100644 --- a/web/src/components/campaigns/new-campaign/CampaignSummary.tsx +++ b/web/src/components/campaigns/new-campaign/CampaignSummary.tsx @@ -5,7 +5,7 @@ import { ChevronDown } from "lucide-react"; import { useCampaign } from "@/components/campaigns/new-campaign/CampaignContext"; import { fetchPhishingKits } from "@/services/phishingKitsApi"; import { fetchSendingProfiles } from "@/services/sendingProfilesApi"; -import { fetchGroupMembers, fetchGroups } from "@/services/userGroupsApi"; +import { userGroupsApi } from "@/services/userGroupsApi"; import type { PhishingKitDisplayInfo } from "@/types/phishingKit"; import type { SendingProfileDisplayInfo } from "@/types/sendingProfile"; @@ -178,7 +178,7 @@ export default function CampaignSummary() { } try { - const response = await fetchGroups(realm, keycloak.token || undefined); + const response = await userGroupsApi.getGroups(realm); if (!cancelled) { const groups = (response.groups || []) .filter((group) => !!group.id && !!group.name) @@ -214,7 +214,7 @@ export default function CampaignSummary() { try { const membersPerGroup = await Promise.all( data.user_group_ids.map((groupId) => - fetchGroupMembers(realm, groupId, keycloak.token || undefined) + userGroupsApi.getGroupMembers(realm, groupId) ) ); diff --git a/web/src/components/campaigns/new-campaign/TagInput.tsx b/web/src/components/campaigns/new-campaign/TagInput.tsx index 853b1c0c..461a9dcb 100644 --- a/web/src/components/campaigns/new-campaign/TagInput.tsx +++ b/web/src/components/campaigns/new-campaign/TagInput.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Plus, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; interface TagInputProps { onAdd: (tag: string) => void; @@ -33,24 +34,13 @@ const TagInput: React.FC = ({ onAdd }) => { ) : (
    {/* Hashtag prefix */} @@ -78,10 +68,10 @@ const TagInput: React.FC = ({ onAdd }) => { type="button" onClick={handleAdd} disabled={!tempTag.trim()} - className="p-1.5 mr-1 rounded-full transition-all duration-150 disabled:opacity-40" - style={{ - background: tempTag.trim() ? 'rgba(34, 197, 94, 0.9)' : 'rgba(148, 163, 184, 0.3)', - }} + className={cn( + "p-1.5 mr-1 rounded-full transition-all duration-150 disabled:opacity-40", + tempTag.trim() ? "bg-success text-success-foreground" : "bg-muted-foreground/30 text-muted-foreground" + )} > diff --git a/web/src/components/campaigns/new-campaign/TargetGroupSelector.tsx b/web/src/components/campaigns/new-campaign/TargetGroupSelector.tsx index ede76bf9..7b7b45f8 100644 --- a/web/src/components/campaigns/new-campaign/TargetGroupSelector.tsx +++ b/web/src/components/campaigns/new-campaign/TargetGroupSelector.tsx @@ -10,7 +10,7 @@ import SelectedGroupCard from "./group-picker/SelectedGroupCard"; import SelectedGroupsEmptyState from "./group-picker/SelectedGroupsEmptyState"; import SearchableMultiPicker from "@/components/shared/multi-picker/SearchableMultiPicker"; import useAsyncMultiPickerItems from "@/components/shared/multi-picker/useAsyncMultiPickerItems"; -import { fetchGroups } from "@/services/userGroupsApi"; +import { userGroupsApi } from "@/services/userGroupsApi"; export default function TargetGroupSelector() { const { data, updateData } = useCampaign(); @@ -26,7 +26,7 @@ export default function TargetGroupSelector() { const loadGroups = useCallback(async (): Promise => { if (!realm) return []; - const response = await fetchGroups(realm, keycloak.token || undefined); + const response = await userGroupsApi.getGroups(realm); return (response.groups || []) .filter((group) => !!group.id && !!group.name) .map((group) => ({ diff --git a/web/src/components/campaigns/timeline/TimelineBar.tsx b/web/src/components/campaigns/timeline/TimelineBar.tsx index c5edefec..d9933c9f 100644 --- a/web/src/components/campaigns/timeline/TimelineBar.tsx +++ b/web/src/components/campaigns/timeline/TimelineBar.tsx @@ -12,19 +12,19 @@ interface TimelineBarProps { // Mapeamento atualizado para os status do Backend Python const statusColors: Record = { running: { - bg: "bg-gradient-to-r from-blue-400 to-blue-500", - border: "border-blue-400/30", + bg: "bg-info", + border: "border-info/30", }, scheduled: { - bg: "bg-gradient-to-r from-amber-300 to-amber-400", - border: "border-amber-400/30", + bg: "bg-warning", + border: "border-warning/30", }, completed: { - bg: "bg-gradient-to-r from-emerald-400 to-emerald-500", - border: "border-emerald-400/30", + bg: "bg-success", + border: "border-success/30", }, canceled: { - bg: "bg-gradient-to-r from-muted-foreground/40 to-muted-foreground/50", + bg: "bg-muted-foreground/40", // Simplificado de gradiente para cor única border: "border-border/30", }, }; diff --git a/web/src/components/campaigns/timeline/TimelineGrid.tsx b/web/src/components/campaigns/timeline/TimelineGrid.tsx index 66c56fb3..c0faa2a7 100644 --- a/web/src/components/campaigns/timeline/TimelineGrid.tsx +++ b/web/src/components/campaigns/timeline/TimelineGrid.tsx @@ -32,7 +32,7 @@ export const TimelineGrid = memo(function TimelineGrid({ campaignColumnWidth={CAMPAIGN_COLUMN_WIDTH} /> -
    +
    {visibleCampaigns.length > 0 ? ( visibleCampaigns.map((campaign) => ( {/* Passed score banner */} -
    -
    +
    +

    Quiz passed

    @@ -34,8 +34,8 @@ export default function ComplianceConfirmStep({ type="button" onClick={() => onAttestChange(!attest)} className={`w-full text-left rounded-lg p-4 flex items-start gap-4 transition-all duration-200 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 ${attest - ? 'border-primary bg-primary/10/60 shadow-sm' - : 'border-border bg-background hover:border-primary/50 hover:bg-primary/10/30' + ? 'border-primary bg-primary/10 shadow-sm' + : 'border-border bg-background hover:border-primary/50 hover:bg-primary/5' }`} > {/* Custom checkbox indicator */} diff --git a/web/src/components/compliance/ComplianceHeader.tsx b/web/src/components/compliance/ComplianceHeader.tsx index 9957bcce..28574488 100644 --- a/web/src/components/compliance/ComplianceHeader.tsx +++ b/web/src/components/compliance/ComplianceHeader.tsx @@ -9,7 +9,7 @@ type ComplianceHeaderProps = { export default function ComplianceHeader({ doc, step }: ComplianceHeaderProps) { return ( -
    +

    Compliance Required

    diff --git a/web/src/components/compliance/ComplianceQuizStep.tsx b/web/src/components/compliance/ComplianceQuizStep.tsx index f3a2ccad..5c9d9000 100644 --- a/web/src/components/compliance/ComplianceQuizStep.tsx +++ b/web/src/components/compliance/ComplianceQuizStep.tsx @@ -39,14 +39,14 @@ export default function ComplianceQuizStep({ Score at least {quiz.required_score}%
    - + {answeredCount}/{quiz.questions.length} answered Required: {quiz.required_score}%+ {remainingCooldown > 0 && ( - + Cooldown: {remainingCooldown}s )} @@ -55,9 +55,9 @@ export default function ComplianceQuizStep({ {/* Failed banner */} {result && !result.passed ? ( -
    +
    -
    +

    You didn't pass this time

    Score {result.score}% (need {result.required_score}%)

    @@ -65,24 +65,24 @@ export default function ComplianceQuizStep({
    {remainingCooldown}s
    -
    +
    Try again in {remainingCooldown}s
    {/* Feedback list */} -
    +

    What to review

      {result.feedback.map((f) => ( -
    • +
    • {f.feedback}
    • ))} @@ -116,7 +116,7 @@ export default function ComplianceQuizStep({ {/* Question list */}
      {quiz.questions?.map((q, idx) => ( -
      +

      {idx + 1}. {q.prompt}

      @@ -133,7 +133,7 @@ export default function ComplianceQuizStep({ > +
      Passed @@ -159,7 +159,7 @@ export default function ComplianceQuizStep({

      Score {result.score}% (need {result.required_score}%)

        {result.feedback.map((f) => ( -
      • {f.feedback}
      • +
      • {f.feedback}
      • ))}
      diff --git a/web/src/components/content-manager/content/ContentDisplay.tsx b/web/src/components/content-manager/content/ContentDisplay.tsx index 6bde43aa..a6e98dd5 100644 --- a/web/src/components/content-manager/content/ContentDisplay.tsx +++ b/web/src/components/content-manager/content/ContentDisplay.tsx @@ -253,7 +253,7 @@ export function ContentDisplay({ placeholder="folder name" className="flex-1 min-w-0 rounded border border-primary/40 px-1.5 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary bg-surface" /> - {creatingFolderIn === ROOT_FOLDER_ID && (
      - + { void confirmCreateFolder(); }} placeholder="folder name" - className="flex-1 min-w-0 rounded border border-[#7C3AED]/40 px-1.5 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#7C3AED] bg-surface" + className="flex-1 min-w-0 rounded border border-primary/40 px-1.5 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary bg-surface" /> - - -
      diff --git a/web/src/components/content-manager/courses/CourseModuleCard.tsx b/web/src/components/content-manager/courses/CourseModuleCard.tsx index 9166dcba..3daedaba 100644 --- a/web/src/components/content-manager/courses/CourseModuleCard.tsx +++ b/web/src/components/content-manager/courses/CourseModuleCard.tsx @@ -10,9 +10,9 @@ interface CourseModuleCardProps { } const DIFFICULTY_COLOR: Record = { - Easy: 'bg-green-500/15 text-green-400 border-green-500/30', - Medium: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30', - Hard: 'bg-red-500/15 text-red-400 border-red-500/30', + Easy: 'bg-success/15 text-success border-success/30', + Medium: 'bg-warning/15 text-warning border-warning/30', + Hard: 'bg-error/15 text-error border-error/30', } export function CourseModuleCard({ @@ -22,7 +22,7 @@ export function CourseModuleCard({ onRemove, dragHandleProps, }: CourseModuleCardProps) { - const difficultyColor = DIFFICULTY_COLOR[module.difficulty ?? ''] ?? 'bg-red-100 text-red-700 border-red-300' + const difficultyColor = DIFFICULTY_COLOR[module.difficulty ?? ''] ?? 'bg-error/10 text-error border-error/20' return (
      diff --git a/web/src/components/content-manager/courses/CoursePreview.tsx b/web/src/components/content-manager/courses/CoursePreview.tsx index 7b23d82d..4a2b931d 100644 --- a/web/src/components/content-manager/courses/CoursePreview.tsx +++ b/web/src/components/content-manager/courses/CoursePreview.tsx @@ -71,15 +71,15 @@ function PreviewBlock({ block, qIndex, answeredChoices, onMark }: { const choiceBtnClass = (c: Choice) => { const isSelected = answered === c.id if (!isSelected) return 'bg-surface border-border text-foreground hover:bg-primary/10 hover:border-primary/30' - if (c.isCorrect) return 'bg-green-50 border-green-400 text-green-800' - return 'bg-red-50 border-red-400 text-red-800' + if (c.isCorrect) return 'bg-success/10 border-success/40 text-success' + return 'bg-error/10 border-error/40 text-error' } const choiceCircleClass = (c: Choice) => { const isSelected = answered === c.id if (!isSelected) return 'border-border' - if (c.isCorrect) return 'border-green-500 bg-green-100' - return 'border-red-500 bg-red-100' + if (c.isCorrect) return 'border-success bg-success/20' + return 'border-error bg-error/20' } return ( @@ -111,8 +111,8 @@ function PreviewBlock({ block, qIndex, answeredChoices, onMark }: { onClick={() => onMark(q.id, c.id)} className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm text-left transition-all ${choiceBtnClass(c)}`}> - {isSelected && c.isCorrect && } - {isSelected && !c.isCorrect && } + {isSelected && c.isCorrect && } + {isSelected && !c.isCorrect && } {c.text || Empty choice} diff --git a/web/src/components/content-manager/modules/module-creation/ContentFilePicker.tsx b/web/src/components/content-manager/modules/module-creation/ContentFilePicker.tsx index 7cda4735..d8446653 100644 --- a/web/src/components/content-manager/modules/module-creation/ContentFilePicker.tsx +++ b/web/src/components/content-manager/modules/module-creation/ContentFilePicker.tsx @@ -74,8 +74,8 @@ function mimeMatchesFilter(contentType: string, filter: PickerMediaFilter): bool function fileIcon(contentType: string) { if (contentType.startsWith('image/')) return - if (contentType.startsWith('video/')) return
      ) if (error) return ( -
      {error}
      +
      {error}
      ) return (
      @@ -426,7 +426,7 @@ export function ContentFilePicker({ token, accept = 'any', onSelect, onClose }: function renderUpload() { if (uploadDone) return (
      - +

      Uploaded! Switching to library…

      ) @@ -524,7 +524,7 @@ export function ContentFilePicker({ token, accept = 'any', onSelect, onClose }:
      {uploadError && ( -

      +

      Upload failed: {uploadError}

      )} diff --git a/web/src/components/content-manager/modules/module-creation/LeaveConfirm.tsx b/web/src/components/content-manager/modules/module-creation/LeaveConfirm.tsx index 445b6818..c5c238ec 100644 --- a/web/src/components/content-manager/modules/module-creation/LeaveConfirm.tsx +++ b/web/src/components/content-manager/modules/module-creation/LeaveConfirm.tsx @@ -22,8 +22,8 @@ export function LeaveConfirm({
      -
      - +
      +

      Unsaved Changes @@ -47,7 +47,7 @@ export function LeaveConfirm({ type="button" disabled={isSaving} onClick={onDiscard} - className="w-full px-4 py-2 text-sm font-medium text-red-600 bg-surface border border-border rounded-lg hover:bg-red-50 hover:border-red-200 transition-colors" + className="w-full px-4 py-2 text-sm font-medium text-error bg-surface border border-border rounded-lg hover:bg-error/10 hover:border-error/20 transition-colors" > Discard @@ -55,7 +55,7 @@ export function LeaveConfirm({ type="button" disabled={isSaving} onClick={onSave} - className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white rounded-lg bg-[#7C3AED] hover:bg-[#6D28D9] transition-colors disabled:opacity-50 shadow-sm shadow-primary/20" + className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white rounded-lg bg-primary hover:bg-primary/80 transition-colors disabled:opacity-50 shadow-sm shadow-primary/20" > {isSaving ? : 'Save & Exit'} diff --git a/web/src/components/content-manager/modules/module-creation/SectionSettingsSidebar.tsx b/web/src/components/content-manager/modules/module-creation/SectionSettingsSidebar.tsx deleted file mode 100644 index de76157d..00000000 --- a/web/src/components/content-manager/modules/module-creation/SectionSettingsSidebar.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from sidebar subfolder — kept for backward compatibility -export { SectionSettingsSidebar } from './sidebar/SectionSettingsSidebar' diff --git a/web/src/components/content-manager/modules/module-creation/SectionsEditor.tsx b/web/src/components/content-manager/modules/module-creation/SectionsEditor.tsx index f92fa379..1f0a7f1e 100644 --- a/web/src/components/content-manager/modules/module-creation/SectionsEditor.tsx +++ b/web/src/components/content-manager/modules/module-creation/SectionsEditor.tsx @@ -293,12 +293,12 @@ export function SectionsEditor({ data, onChange, publishAttempted, getToken }: {
      {view === 'module' ? ( ) : ( )} @@ -335,7 +335,7 @@ export function SectionsEditor({ data, onChange, publishAttempted, getToken }: {

      No sections yet

      Add a section to start building your module

      @@ -370,7 +370,7 @@ export function SectionsEditor({ data, onChange, publishAttempted, getToken }: {

      No refresh sections yet

      Shorter content that reinforces the main module

      diff --git a/web/src/components/content-manager/modules/module-creation/StorageModal.tsx b/web/src/components/content-manager/modules/module-creation/StorageModal.tsx index 3a68c89e..20e5f299 100644 --- a/web/src/components/content-manager/modules/module-creation/StorageModal.tsx +++ b/web/src/components/content-manager/modules/module-creation/StorageModal.tsx @@ -220,7 +220,7 @@ export function StorageModal({ isOpen, onClose, onImport, data }: StorageModalPr type="button" onClick={handleCopyPromptGuide} title="Copy Prompt Guide (LLM Generation)" - className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-foreground hover:bg-[#7C3AED]/10 hover:border-[#7C3AED]/40 hover:text-[#A78BFA] transition-colors" + className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-foreground hover:bg-primary/10 hover:border-primary/40 hover:text-primary transition-colors" > @@ -268,7 +268,7 @@ export function StorageModal({ isOpen, onClose, onImport, data }: StorageModalPr wrap="off" className="w-full h-40 bg-surface-subtle border border-border rounded-xl px-4 py-4 text-[13px] font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 text-foreground overflow-auto whitespace-pre" /> - {importError &&

      {importError}

      } + {importError &&

      {importError}

      }
      @@ -55,7 +55,7 @@ function MdDropdown({ label, options, onInsert }: { {options.map(opt => ( ))} @@ -87,7 +87,7 @@ export function MarkdownBlockEditor({ block, onUpdate, onRemove, publishAttempte return (
      @@ -118,7 +118,7 @@ export function MarkdownBlockEditor({ block, onUpdate, onRemove, publishAttempte
      diff --git a/web/src/components/content-manager/modules/module-creation/blocks/QuestionBlockEditor.tsx b/web/src/components/content-manager/modules/module-creation/blocks/QuestionBlockEditor.tsx index fb16973d..8877eb8f 100644 --- a/web/src/components/content-manager/modules/module-creation/blocks/QuestionBlockEditor.tsx +++ b/web/src/components/content-manager/modules/module-creation/blocks/QuestionBlockEditor.tsx @@ -46,7 +46,7 @@ export function QuestionBlockEditor({ block, onUpdate, onRemove, publishAttempte return (
      @@ -67,7 +67,7 @@ export function QuestionBlockEditor({ block, onUpdate, onRemove, publishAttempte
      @@ -77,7 +77,7 @@ export function QuestionBlockEditor({ block, onUpdate, onRemove, publishAttempte value={q.text} onChange={e => onUpdate({ ...q, text: e.target.value })} placeholder="Type your question here..." - className="w-full text-sm bg-surface-subtle border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#7C3AED]/30 text-foreground placeholder:text-muted-foreground" + className="w-full text-sm bg-surface-subtle border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary/30 text-foreground placeholder:text-muted-foreground" />

      @@ -87,7 +87,7 @@ export function QuestionBlockEditor({ block, onUpdate, onRemove, publishAttempte value={q.answer} onChange={e => onUpdate({ ...q, answer: e.target.value })} placeholder="Expected answer (optional)..." - className="w-full text-sm bg-surface-subtle border border-dashed border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#7C3AED]/30 text-foreground placeholder:text-muted-foreground italic" + className="w-full text-sm bg-surface-subtle border border-dashed border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary/30 text-foreground placeholder:text-muted-foreground italic" /> ) : ( <> @@ -107,12 +107,12 @@ export function QuestionBlockEditor({ block, onUpdate, onRemove, publishAttempte value={choice.text} onChange={e => patchChoice(choice.id, { text: e.target.value })} placeholder={`Option ${ci + 1}`} - className="flex-1 text-sm bg-surface-subtle border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#7C3AED]/30 text-foreground placeholder:text-muted-foreground" + className="flex-1 text-sm bg-surface-subtle border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary/30 text-foreground placeholder:text-muted-foreground" /> )} {q.type === 'multiple_choice' && ( )} diff --git a/web/src/components/content-manager/modules/module-creation/blocks/RichContentBlockEditor.tsx b/web/src/components/content-manager/modules/module-creation/blocks/RichContentBlockEditor.tsx index e7c5f262..10880f6d 100644 --- a/web/src/components/content-manager/modules/module-creation/blocks/RichContentBlockEditor.tsx +++ b/web/src/components/content-manager/modules/module-creation/blocks/RichContentBlockEditor.tsx @@ -47,8 +47,8 @@ export function RichContentBlockEditor({ block, onUpdate, onRemove, getToken, pu return ( <> -
      +
      Media @@ -70,7 +70,7 @@ export function RichContentBlockEditor({ block, onUpdate, onRemove, getToken, pu
      @@ -83,7 +83,7 @@ export function RichContentBlockEditor({ block, onUpdate, onRemove, getToken, pu {block.contentId ? ( /* ── Platform file chosen — show media preview with overlay controls ── */ -
      +
      {/* Media preview */} {block.mediaType === 'image' && block.url && ( {block.caption = { - Easy: 'bg-green-100 text-green-700 border-green-300', - Medium: 'bg-yellow-100 text-yellow-700 border-yellow-300', - Hard: 'bg-red-100 text-red-700 border-red-300', + Easy: 'bg-success/10 text-success border-success/20', + Medium: 'bg-warning/10 text-warning border-warning/20', + Hard: 'bg-error/10 text-error border-error/20', } -export const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-300/50' +export const inputCls = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50' export const MD_HEADING_OPTIONS = [ { label: 'H1 — Title', insert: '\n# Heading\n' }, diff --git a/web/src/components/content-manager/modules/module-creation/index.tsx b/web/src/components/content-manager/modules/module-creation/index.tsx index 3c334620..6fae3fd3 100644 --- a/web/src/components/content-manager/modules/module-creation/index.tsx +++ b/web/src/components/content-manager/modules/module-creation/index.tsx @@ -213,7 +213,7 @@ function ModuleCreationFormInner({ getToken, onBack, initialData, initialModuleI @@ -243,7 +243,7 @@ function ModuleCreationFormInner({ getToken, onBack, initialData, initialModuleI type="button" onClick={togglePreview} title="Preview Module" - className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-foreground hover:bg-[#7C3AED]/10 hover:border-[#7C3AED]/40 hover:text-[#A78BFA] transition-colors" + className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-foreground hover:bg-primary/10 hover:border-primary/40 hover:text-primary transition-colors" > @@ -252,7 +252,7 @@ function ModuleCreationFormInner({ getToken, onBack, initialData, initialModuleI type="button" disabled={actionStatus === 'loading'} onClick={handleSave} - className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-semibold bg-[#7C3AED] text-white hover:bg-[#7C3AED] transition-colors shadow-sm shadow-[#7C3AED]/25 disabled:opacity-40 disabled:cursor-not-allowed" + className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-semibold bg-primary text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/25 disabled:opacity-40 disabled:cursor-not-allowed" > Save {(actionStatus === 'loading' || saveStatus === 'saving') && } diff --git a/web/src/components/content-manager/modules/module-creation/preview/ModulePreview.tsx b/web/src/components/content-manager/modules/module-creation/preview/ModulePreview.tsx index b393755a..8c0f53bc 100644 --- a/web/src/components/content-manager/modules/module-creation/preview/ModulePreview.tsx +++ b/web/src/components/content-manager/modules/module-creation/preview/ModulePreview.tsx @@ -50,7 +50,7 @@ function RichMediaPreview({ block }: { readonly block: Extract + className="flex items-center gap-2 px-4 py-3 text-sm text-accent-secondary font-medium hover:text-accent-secondary/90 transition-colors"> {block.url} @@ -71,21 +71,21 @@ function QuestionPreview({ block, qIndex, accent, answeredChoices, onMark }: { const choiceBtnClass = (c: Choice) => { if (answered !== c.id) { - return `bg-surface border-border text-foreground ${accent === 'teal' ? 'hover:bg-teal-50/50 hover:border-teal-200' : 'hover:bg-[#7C3AED]/10/50 hover:border-[#7C3AED]/30'}` + return `bg-surface border-border text-foreground ${accent === 'teal' ? 'hover:bg-accent-secondary/10 hover:border-accent-secondary/30' : 'hover:bg-primary/10 hover:border-primary/30'}` } - return c.isCorrect ? 'bg-green-50 border-green-400 text-green-800' : 'bg-red-50 border-red-400 text-red-800' + return c.isCorrect ? 'bg-success/10 border-success/40 text-success' : 'bg-error/10 border-error/40 text-error' } const choiceCircleClass = (c: Choice) => { if (answered !== c.id) return 'border-border' - return c.isCorrect ? 'border-green-500 bg-green-100' : 'border-red-500 bg-red-100' + return c.isCorrect ? 'border-success bg-success/20' : 'border-error bg-error/20' } return (
      - - {typeLabel} + + {typeLabel} Q{qIndex}
      @@ -95,9 +95,9 @@ function QuestionPreview({ block, qIndex, accent, answeredChoices, onMark }: { {q.type === 'short_answer' ? (
      + className={`flex-1 bg-surface-subtle border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 ${accent === 'teal' ? 'focus:ring-accent-secondary/50 focus:border-accent-secondary' : 'focus:ring-primary/40 focus:border-primary/50'}`} />
      @@ -110,8 +110,8 @@ function QuestionPreview({ block, qIndex, accent, answeredChoices, onMark }: { onClick={() => onMark(q.id, c.id)} className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm text-left transition-all ${choiceBtnClass(c)}`}> - {isSelected && c.isCorrect && } - {isSelected && !c.isCorrect && } + {isSelected && c.isCorrect && } + {isSelected && !c.isCorrect && } {c.text || Empty choice} @@ -134,7 +134,7 @@ function PreviewBlock({ block, qIndex, accent, answeredChoices, onMark }: { if (block.kind === 'text') { return (
      ) @@ -223,8 +223,8 @@ export function ModulePreview({ data, onClose }: { readonly data: ModuleFormData
      {/* Left: icon + title + meta */}
      -
      - {accent === 'teal' ? : } +
      + {accent === 'teal' ? : }
      {data.title || Untitled Module} @@ -283,8 +283,8 @@ export function ModulePreview({ data, onClose }: { readonly data: ModuleFormData cover ) : ( -
      - {accent === 'teal' ? : } +
      + {accent === 'teal' ? : }
      )} {data.description && ( @@ -307,9 +307,9 @@ export function ModulePreview({ data, onClose }: { readonly data: ModuleFormData return ( diff --git a/web/src/components/content-manager/modules/module-creation/sections/SectionRuleIcons.tsx b/web/src/components/content-manager/modules/module-creation/sections/SectionRuleIcons.tsx index 7b4f8bd9..3d3fd5d8 100644 --- a/web/src/components/content-manager/modules/module-creation/sections/SectionRuleIcons.tsx +++ b/web/src/components/content-manager/modules/module-creation/sections/SectionRuleIcons.tsx @@ -10,9 +10,9 @@ export function SectionRuleIcons({ section, withTooltips }: { if (!withTooltips) { return (
      - {section.requireCorrectAnswers && } - {section.isOptional && } - {!!section.minTimeSpent && } + {section.requireCorrectAnswers && } + {section.isOptional && } + {!!section.minTimeSpent && }
      ) } @@ -21,7 +21,7 @@ export function SectionRuleIcons({ section, withTooltips }: {
      {section.requireCorrectAnswers && (
      - + Correct answers required @@ -29,7 +29,7 @@ export function SectionRuleIcons({ section, withTooltips }: { )} {section.isOptional && (
      - + Optional section @@ -37,7 +37,7 @@ export function SectionRuleIcons({ section, withTooltips }: { )} {!!section.minTimeSpent && section.minTimeSpent > 0 && (
      - + Min. {section.minTimeSpent}s required diff --git a/web/src/components/content-manager/modules/module-creation/sections/SectionTitleEditor.tsx b/web/src/components/content-manager/modules/module-creation/sections/SectionTitleEditor.tsx index b98f0e3f..5e7ae415 100644 --- a/web/src/components/content-manager/modules/module-creation/sections/SectionTitleEditor.tsx +++ b/web/src/components/content-manager/modules/module-creation/sections/SectionTitleEditor.tsx @@ -34,7 +34,7 @@ export function SectionTitleEditor({ section, onUpdate, onStopEdit, titleMissing placeholder="Section title..." style={{ minWidth: '10ch' }} className={`text-sm font-semibold text-foreground bg-surface rounded-md px-2 py-0.5 focus:outline-none w-full ${titleMissing - ? 'border border-amber-400 focus:ring-amber-300/40' + ? 'border border-warning focus:ring-warning/40' : `border ${theme.inputBorder} ${theme.inputRing}` }`} /> @@ -53,7 +53,7 @@ export function SectionTitleEditor({ section, onUpdate, onStopEdit, titleMissing > {(() => { let textCls = 'text-muted-foreground' - if (titleMissing) textCls = 'text-amber-500' + if (titleMissing) textCls = 'text-warning' else if (section.title) textCls = 'text-foreground' return ( @@ -62,7 +62,7 @@ export function SectionTitleEditor({ section, onUpdate, onStopEdit, titleMissing ) })()} {titleMissing - ? + ? : } diff --git a/web/src/components/content-manager/modules/module-creation/sections/ViewTabToggle.tsx b/web/src/components/content-manager/modules/module-creation/sections/ViewTabToggle.tsx index bc39b4e8..012eac26 100644 --- a/web/src/components/content-manager/modules/module-creation/sections/ViewTabToggle.tsx +++ b/web/src/components/content-manager/modules/module-creation/sections/ViewTabToggle.tsx @@ -27,13 +27,13 @@ export function ViewTabToggle({ view, setView, mainCount, refreshCount }: { type="button" onClick={() => setView('refresh')} className={`flex items-center gap-1.5 px-4 py-1.5 text-sm font-semibold transition-all ${view === 'refresh' - ? 'bg-teal-500 text-white' - : 'bg-surface text-muted-foreground hover:bg-surface-subtle hover:text-teal-600' + ? 'bg-accent-secondary text-primary-foreground' + : 'bg-surface text-muted-foreground hover:bg-surface-subtle hover:text-accent-secondary' }`} > Refresh {refreshCount > 0 && ( - {refreshCount} diff --git a/web/src/components/content-manager/modules/module-creation/sidebar/DetailsSidebar.tsx b/web/src/components/content-manager/modules/module-creation/sidebar/DetailsSidebar.tsx index 848250fd..5ad74ce7 100644 --- a/web/src/components/content-manager/modules/module-creation/sidebar/DetailsSidebar.tsx +++ b/web/src/components/content-manager/modules/module-creation/sidebar/DetailsSidebar.tsx @@ -33,10 +33,10 @@ function FormField({ htmlFor={htmlFor} className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide" > - {icon && {icon}} + {icon && {icon}} {label} - {isRequired && *} + {isRequired && *} {children} @@ -76,7 +76,7 @@ function SidebarFields({
    ${c.trim()}${c.trim()}${c.trim()}${c.trim()}