diff --git a/.env.example b/.env.example index 210be31..65625fe 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ GRAFANA_PASSWORD=change-this-password-in-production CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 +# Registration +# Set to "false" to disable user registration. For prod, build the image with: +# docker build --build-arg REGISTRATION_ENABLED=false -t your-image . +REGISTRATION_ENABLED=true + # MinIO Configuration (Object Storage) # MinIO server stays hidden - Flask proxies all requests # MINIO_ENDPOINT: Internal Docker network address (for server-to-server communication) diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 7b4fc6c..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies -COPY requirements/ requirements/ -RUN pip install --no-cache-dir -r requirements/prod.txt - -# Copy application code -COPY . . - -# Create non-root user -RUN useradd -m appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 8000 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000/health/ || exit 1 - -# Run with gunicorn -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "wsgi:app"] \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 5816b54..a318783 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,6 +16,7 @@ class Config: MAX_FAILED_LOGIN_ATTEMPTS = int(os.environ.get("MAX_FAILED_LOGIN_ATTEMPTS", "5")) ACCOUNT_LOCK_DURATION_MINUTES = int(os.environ.get("ACCOUNT_LOCK_DURATION_MINUTES", "30")) LOCKOUT_STRATEGY = os.environ.get("LOCKOUT_STRATEGY", "timeout") # "timeout" | "permanent" + REGISTRATION_ENABLED = os.environ.get("REGISTRATION_ENABLED", "true").lower() == "true" # Celery Configuration CELERY = { diff --git a/backend/app/models/login_history.py b/backend/app/models/login_history.py index 8ca2985..6c21d42 100644 --- a/backend/app/models/login_history.py +++ b/backend/app/models/login_history.py @@ -32,7 +32,7 @@ class LoginHistory(db.Model): "ip_address": self.ip_address, "user_agent": self.user_agent, "success": self.success, - "created_at": self.created_at.isoformat() if self.created_at else None, + "created_at": self.created_at if self.created_at else None, } def __repr__(self): diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index 1eb3904..fa8adb7 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -1,6 +1,6 @@ """Auth and user management API routes""" -from flask import Blueprint, jsonify, request +from flask import Blueprint, current_app, jsonify, request from flask_jwt_extended import ( create_access_token, create_refresh_token, @@ -24,6 +24,9 @@ api_bp = Blueprint("api", __name__) @api_bp.route("/auth/register", methods=["POST"]) def register(): """Register a new user""" + if not current_app.config.get("REGISTRATION_ENABLED", True): + return jsonify({"error": "Not found"}), 404 + try: data = request.get_json() diff --git a/backend/gunicorn.conf.py b/backend/gunicorn.conf.py new file mode 100644 index 0000000..7ffc423 --- /dev/null +++ b/backend/gunicorn.conf.py @@ -0,0 +1,36 @@ +"""Gunicorn configuration. + +All Gunicorn settings live here instead of the Dockerfile CMD so they can be +tweaked without rebuilding the image, and so the access-log format/timezone are +defined in one place. +""" + +import os +import time + + +# ---- Server ---- +bind = "0.0.0.0:8000" +workers = 2 + +# ---- Logging ---- +accesslog = "-" +errorlog = "-" +capture_output = True +loglevel = "info" + +# %(h)s = real client IP. Gunicorn resolves X-Forwarded-For based on its built-in +# proxy trust logic (the same mechanism already validated behind Traefik). +# %(t)s = overridden by CustomLogger -> [2026-08-09 20:41:00 +0300] +access_log_format = '%(h)s - - [%(t)s] "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' +logger_class = "gunicorn_logger.CustomLogger" + +# logger_class = CustomLogger + +# ---- Timezone for Python logging / error logs ---- +# Default to UTC unless TZ is set (e.g. TZ=Africa/Nairobi at docker run time). +os.environ.setdefault("TZ", os.environ.get("TZ", "UTC")) +try: + time.tzset() +except AttributeError: # non-Unix + pass \ No newline at end of file diff --git a/backend/gunicorn_logger.py b/backend/gunicorn_logger.py new file mode 100644 index 0000000..95fe7fc --- /dev/null +++ b/backend/gunicorn_logger.py @@ -0,0 +1,29 @@ +"""Custom Gunicorn logger. + +Overrides the default timestamp atom `%(t)s` in access logs from Gunicorn's +default format `[09/Aug/2026:17:42:48 +0000]` to an ISO-like local-time format +`[2026-08-09 20:41:00 +0300]`, honoring the TZ environment variable. +""" + +import os +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from gunicorn.glogging import Logger + +TIMEZONE = os.environ.get("TZ", "UTC") + + +class CustomLogger(Logger): + """Gunicorn logger that emits timezone-aware ISO timestamps.""" + + def atoms(self, resp, req, environ, request_time): + """Build the log atoms, overriding the time atom with a custom format.""" + atoms = super().atoms(resp, req, environ, request_time) + try: + now = datetime.now(ZoneInfo(TIMEZONE)) + except Exception: + now = datetime.now(timezone.utc) + # [09/Aug/2026:17:42:48 +0000] -> [2026-08-09 20:41:00 +0300] + atoms["t"] = f"[{now.strftime('%Y-%m-%d %H:%M:%S %z')}]" + return atoms \ No newline at end of file diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index b8e0ab4..de1e6b7 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -82,6 +82,23 @@ class TestAuthRoutes: data = response.get_json() assert "already exists" in data["error"].lower() + @pytest.mark.auth + def test_register_disabled(self, app, client, monkeypatch): + """Test registration returns 404 when disabled via config""" + monkeypatch.setitem(app.config, "REGISTRATION_ENABLED", False) + response = client.post( + "/api/auth/register", + json={ + "email": "disabled@example.com", + "password": "password123", + "confirm_password": "password123", + }, + ) + + assert response.status_code == 404 + data = response.get_json() + assert data["error"] == "Not found" + @pytest.mark.auth def test_login_success(self, client, regular_user): """Test successful login""" diff --git a/backend/wsgi.py b/backend/wsgi.py index 489fcd0..99b105a 100644 --- a/backend/wsgi.py +++ b/backend/wsgi.py @@ -1,8 +1,11 @@ import os from app import create_app +from werkzeug.middleware.proxy_fix import ProxyFix env = os.environ.get('FLASK_ENV', 'dev') app = create_app(env) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + if __name__ == '__main__': app.run(host='0.0.0.0', port=8000) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 3c19411..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,191 +0,0 @@ -version: '3.8' - -services: - backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: crafting-shop-backend - ports: - - "5000:5000" - environment: - - FLASK_ENV=${FLASK_ENV:-prod} - - SECRET_KEY=${SECRET_KEY} - - JWT_SECRET_KEY=${JWT_SECRET_KEY} - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - depends_on: - - postgres - - redis - networks: - - crafting-shop-network - volumes: - - backend-data:/app/instance - restart: unless-stopped - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - container_name: crafting-shop-frontend - ports: - - "80:80" - depends_on: - - backend - networks: - - crafting-shop-network - restart: unless-stopped - - postgres: - image: postgres:15-alpine - container_name: crafting-shop-postgres - environment: - - POSTGRES_USER=${POSTGRES_USER:-crafting} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB:-crafting_shop} - volumes: - - postgres-data:/var/lib/postgresql/data - networks: - - crafting-shop-network - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: crafting-shop-redis - networks: - - crafting-shop-network - restart: unless-stopped - - minio: - image: minio/minio:latest - container_name: crafting-shop-minio - command: server /data --console-address ":9001" - ports: - - "9000:9000" - - "9001:9001" - environment: - - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - volumes: - - minio-data:/data - networks: - - crafting-shop-network - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 30s - timeout: 20s - retries: 3 - - celery_worker: - build: - context: ./backend - dockerfile: Dockerfile - container_name: crafting-shop-celery-worker - command: celery -A celery_worker worker --loglevel=info --concurrency=4 - environment: - - FLASK_ENV=${FLASK_ENV:-prod} - - SECRET_KEY=${SECRET_KEY} - - JWT_SECRET_KEY=${JWT_SECRET_KEY} - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - - CELERY_BROKER_URL=redis://redis:6379/0 - - CELERY_RESULT_BACKEND=redis://redis:6379/0 - depends_on: - - redis - - postgres - - backend - networks: - - crafting-shop-network - restart: unless-stopped - healthcheck: - test: ["CMD", "celery", "-A", "celery_worker", "inspect", "ping", "-d", "celery@$$HOSTNAME"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - - celery_beat: - build: - context: ./backend - dockerfile: Dockerfile - container_name: crafting-shop-celery-beat - command: celery -A celery_worker beat --loglevel=info - environment: - - FLASK_ENV=${FLASK_ENV:-prod} - - SECRET_KEY=${SECRET_KEY} - - JWT_SECRET_KEY=${JWT_SECRET_KEY} - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - - CELERY_BROKER_URL=redis://redis:6379/0 - - CELERY_RESULT_BACKEND=redis://redis:6379/0 - depends_on: - - redis - - postgres - - backend - networks: - - crafting-shop-network - restart: unless-stopped - volumes: - - celery-beat-data:/app/celerybeat - - flower: - build: - context: ./backend - dockerfile: Dockerfile - container_name: crafting-shop-flower - command: celery -A celery_worker flower --port=5555 - ports: - - "5555:5555" - environment: - - FLASK_ENV=${FLASK_ENV:-prod} - - SECRET_KEY=${SECRET_KEY} - - JWT_SECRET_KEY=${JWT_SECRET_KEY} - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - - CELERY_BROKER_URL=redis://redis:6379/0 - - CELERY_RESULT_BACKEND=redis://redis:6379/0 - depends_on: - - redis - - celery_worker - networks: - - crafting-shop-network - restart: unless-stopped - - prometheus: - image: prom/prometheus:latest - container_name: crafting-shop-prometheus - ports: - - "9090:9090" - volumes: - - ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus-data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - networks: - - crafting-shop-network - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: crafting-shop-grafana - ports: - - "3001:3000" - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} - volumes: - - grafana-data:/var/lib/grafana - networks: - - crafting-shop-network - restart: unless-stopped - -volumes: - postgres-data: - redis-data: - prometheus-data: - grafana-data: - backend-data: - celery-beat-data: - minio-data: - -networks: - crafting-shop-network: - driver: bridge \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index fa2d5fb..a02df59 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,6 +3,10 @@ # --------------------------------------------------------- FROM node:18-alpine AS frontend-build +# Build-time configurable: pass --build-arg REGISTRATION_ENABLED=false to disable registration +ARG REGISTRATION_ENABLED=true +ENV VITE_REGISTRATION_ENABLED=$REGISTRATION_ENABLED + WORKDIR /app/frontend # Copy package files first for better caching @@ -20,6 +24,10 @@ FROM python:3.11-slim WORKDIR /app +# Build-time configurable for the backend: pass --build-arg REGISTRATION_ENABLED=false to disable registration +ARG REGISTRATION_ENABLED=true +ENV REGISTRATION_ENABLED=$REGISTRATION_ENABLED + # Install system dependencies (if needed for python packages) # RUN apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/* @@ -41,10 +49,13 @@ USER appuser # Expose port EXPOSE 8000 -# Run with Gunicorn (Production WSGI Server) -# --bind 0.0.0.0 makes it accessible outside the container -# --access-logfile - sends access logs to stdout -# --error-logfile - sends error logs to stderr -# --capture-output ensures all output is captured -# --log-level info sets the logging level -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--log-level", "info", "wsgi:app"] +# # Run with Gunicorn (Production WSGI Server) +# # --bind 0.0.0.0 makes it accessible outside the container +# # --access-logfile - sends access logs to stdout +# # --error-logfile - sends error logs to stderr +# # --capture-output ensures all output is captured +# # --log-level info sets the logging level +# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--access-logformat", "%({x-forwarded-for}i)s - - [%(t)s] \"%(r)s\" %(s)s %(b)s \"%(f)s\" \"%(a)s\"", "--error-logfile", "-", "--capture-output", "--log-level", "info", "wsgi:app"] + +ENV TZ=Africa/Nairobi +CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"] \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index 0c4332f..0000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,42 +0,0 @@ -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Add real IP logging to Nginx access logs - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - location / { - try_files $uri $uri/ /index.html; - } - - location /api { - proxy_pass http://backend:5000; - - # Forward the real IP - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Add these headers too - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - } - - location /health { - proxy_pass http://backend:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript; -} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 097ad2f..831cea5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { Navbar } from './components/Navbar'; import { Home } from './pages/Home'; import Login from './pages/Login'; import { Register } from './pages/Register'; +import { REGISTRATION_ENABLED } from './config/registration'; import Profile from './pages/Profile'; import { ProtectedRoute } from './components/ProtectedRoute'; import { Boards } from './pages/Boards'; @@ -26,6 +27,7 @@ import CreateWiki from './pages/CreateWiki'; import { WikiDetail } from './pages/WikiDetail'; import { EditWiki } from './pages/EditWiki'; import { CardDetail } from './pages/CardDetail'; +import { NotFound } from './pages/NotFound'; import { BoardDetailLayout } from './components/BoardDetailLayout'; const App = () => { @@ -59,7 +61,10 @@ const App = () => { /> } /> } /> - } /> + : } + /> { } /> + } /> {/* Order matters for Z-Index: Loader (70) > Toast (60) > Modal (50) */} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 7210bdc..0700532 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useApp } from '../context/AppContext'; import { useAuth } from '../hooks/useAuth'; import { TaskboardLogo } from './TaskboardLogo'; +import { REGISTRATION_ENABLED } from '../config/registration'; import MenuIcon from './icons/MenuIcon'; import CloseIcon from './icons/CloseIcon'; import UserIcon from './icons/UserIcon'; @@ -69,12 +70,14 @@ export function Navbar() { > Login - - Register - + {REGISTRATION_ENABLED && ( + + Register + + )} )} @@ -136,13 +139,15 @@ export function Navbar() { > Login - setMobileMenuOpen(false)} - > - Register - + {REGISTRATION_ENABLED && ( + setMobileMenuOpen(false)} + > + Register + + )} )} diff --git a/frontend/src/config/registration.ts b/frontend/src/config/registration.ts new file mode 100644 index 0000000..96a8c0c --- /dev/null +++ b/frontend/src/config/registration.ts @@ -0,0 +1,5 @@ +// Inlined at build time by Vite via import.meta.env. +// Unset (local dev / no build arg) → registration is enabled (backward compatible). +// Set to "false" via docker build --build-arg REGISTRATION_ENABLED=false → registration disabled. +export const REGISTRATION_ENABLED = + import.meta.env.VITE_REGISTRATION_ENABLED !== 'false'; \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index c962cdb..3af321c 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -3,7 +3,7 @@ import { useApi } from './useApi'; import { useLoader } from '../context/loaders/useLoader'; import { useToast } from '../context/toasts/useToast'; import { useApp } from '../context/AppContext'; -import { User } from '../types'; +import { RegisterData, User } from '../types'; export function useAuth() { const navigate = useNavigate(); @@ -58,13 +58,7 @@ export function useAuth() { } }; - const handleRegister = async (userData: { - email: string; - password: string; - username: string; - first_name?: string; - last_name?: string; - }) => { + const handleRegister = async (userData: RegisterData) => { try { const response = await withLoader(() => registerApi(userData), 'Creating account...'); diff --git a/frontend/src/hooks/useCardMutations.ts b/frontend/src/hooks/useCardMutations.ts index e712fdd..c2d95fc 100644 --- a/frontend/src/hooks/useCardMutations.ts +++ b/frontend/src/hooks/useCardMutations.ts @@ -3,7 +3,7 @@ import { useLoader } from '../context/loaders/useLoader'; import { useToast } from '../context/toasts/useToast'; import { Card } from '../types/kanban'; -export function useCardMutations(boardId: number, onUpdate: () => void) { +export function useCardMutations(_boardId: number, onUpdate: () => void) { const { createCard, updateCard, deleteCard } = useApi(); const { withLoader } = useLoader(); const { addNotification } = useToast(); @@ -69,7 +69,7 @@ export function useCardMutations(boardId: number, onUpdate: () => void) { const moveCard = async ( card: Card, - fromListId: number, + _fromListId: number, toListId: number, newPosition: number ) => { diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d87dc44..347ca48 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Link, useSearchParams } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; +import { REGISTRATION_ENABLED } from '../config/registration'; const loginSchema = z.object({ email: z.string().min(1, 'Email is required').email('Invalid email address'), @@ -81,12 +82,14 @@ export default function Login() { -

- Don‘t have an account? - - Register - -

+ {REGISTRATION_ENABLED && ( +

+ Don‘t have an account? + + Register + +

+ )} ); } diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..00b4ff8 --- /dev/null +++ b/frontend/src/pages/NotFound.tsx @@ -0,0 +1,30 @@ +import { Link } from "react-router-dom"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import { TaskboardLogo } from "../components/TaskboardLogo"; + +export function NotFound() { + useDocumentTitle("Page Not Found"); + + return ( +
+
+
+ +
+

404

+

Page Not Found

+

+ The page you are looking for doesn't exist or may have been moved. +

+
+ + Go Home + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file