Ver Fonte

new relase

Paweł Malicki há 2 semanas atrás
pai
commit
904b2a4e19
42 ficheiros alterados com 4626 adições e 0 exclusões
  1. 83 0
      Makefile
  2. 29 0
      backend/Dockerfile
  3. 47 0
      backend/alembic.ini
  4. 78 0
      backend/alembic/env.py
  5. 29 0
      backend/alembic/script.py.mako
  6. 116 0
      backend/alembic/versions/0001_initial.py
  7. 234 0
      backend/app/auth.py
  8. 58 0
      backend/app/config.py
  9. 260 0
      backend/app/cron.py
  10. 191 0
      backend/app/crud.py
  11. 38 0
      backend/app/database.py
  12. 125 0
      backend/app/mail.py
  13. 238 0
      backend/app/main.py
  14. 241 0
      backend/app/models.py
  15. 29 0
      backend/requirements.txt
  16. 94 0
      docker-compose.yml
  17. 18 0
      frontend/Dockerfile
  18. 22 0
      frontend/index.html
  19. 25 0
      frontend/package.json
  20. 6 0
      frontend/postcss.config.js
  21. 7 0
      frontend/src/App.vue
  22. 39 0
      frontend/src/api/index.js
  23. 229 0
      frontend/src/components/Dashboard.vue
  24. 236 0
      frontend/src/components/Tabs/ClientsTab.vue
  25. 162 0
      frontend/src/components/Tabs/ConfigTab.vue
  26. 460 0
      frontend/src/components/Tabs/InvoicesTab.vue
  27. 134 0
      frontend/src/components/Tabs/NotificationsTab.vue
  28. 146 0
      frontend/src/components/Tabs/TemplatesTab.vue
  29. 74 0
      frontend/src/components/ui/ConfirmDialog.vue
  30. 94 0
      frontend/src/components/ui/Modal.vue
  31. 10 0
      frontend/src/main.js
  32. 36 0
      frontend/src/router/index.js
  33. 92 0
      frontend/src/store/auth.js
  34. 64 0
      frontend/src/store/clients.js
  35. 117 0
      frontend/src/store/invoices.js
  36. 46 0
      frontend/src/store/notifications.js
  37. 63 0
      frontend/src/store/templates.js
  38. 104 0
      frontend/src/style.css
  39. 179 0
      frontend/src/views/LoginView.vue
  40. 300 0
      frontend/src/views/MFASetupView.vue
  41. 40 0
      frontend/tailwind.config.js
  42. 33 0
      frontend/vite.config.js

+ 83 - 0
Makefile

@@ -0,0 +1,83 @@
+# ─────────────────────────────────────────────────────────────────────────────
+#  PMDI — Pay My Damn Invoice  |  Developer Makefile
+# ─────────────────────────────────────────────────────────────────────────────
+# Usage:
+#   make up         — start the full Docker stack
+#   make down       — stop and remove containers
+#   make logs       — tail all container logs
+#   make cron       — trigger the billing scan manually
+#   make migrate    — run pending Alembic migrations inside the backend container
+#   make autogen    — generate a new migration from model changes
+#   make shell-be   — open a bash shell inside the backend container
+#   make shell-db   — open psql inside the db container
+#
+# Requires:  docker compose, curl, jq
+# ─────────────────────────────────────────────────────────────────────────────
+
+.PHONY: up down restart logs cron migrate autogen shell-be shell-db help
+
+COMPOSE  := docker compose
+BACKEND  := pmdi_backend
+DB       := pmdi_db
+API_BASE := http://localhost:8000/api
+
+# Load CRON_SECRET_TOKEN from .env for the manual cron trigger
+-include .env
+export
+
+# ── Stack management ───────────────────────────────────────────────────────────
+
+up:
+	$(COMPOSE) up -d --build
+
+down:
+	$(COMPOSE) down
+
+restart:
+	$(COMPOSE) restart
+
+logs:
+	$(COMPOSE) logs -f --tail=100
+
+# ── Cron manual trigger ────────────────────────────────────────────────────────
+#  Sends a POST to /api/cron/run with the CRON_SECRET_TOKEN from .env
+#  Pipe through jq if available, fall back to plain output.
+cron:
+	@echo "▶  Triggering billing scan…"
+	@curl -s -X POST $(API_BASE)/cron/run \
+	  -H "Authorization: Bearer $(CRON_SECRET_TOKEN)" \
+	  -H "Content-Type: application/json" \
+	| (command -v jq > /dev/null 2>&1 && jq . || cat)
+	@echo ""
+
+# ── Database migrations ────────────────────────────────────────────────────────
+migrate:
+	@echo "▶  Running Alembic migrations…"
+	$(COMPOSE) exec $(BACKEND) alembic upgrade head
+
+autogen:
+	@echo "▶  Auto-generating migration from model changes…"
+	$(COMPOSE) exec $(BACKEND) alembic revision --autogenerate -m "$(MSG)"
+
+# ── Shells ─────────────────────────────────────────────────────────────────────
+shell-be:
+	$(COMPOSE) exec $(BACKEND) bash
+
+shell-db:
+	$(COMPOSE) exec $(DB) psql -U $${POSTGRES_USER:-pmdi} -d $${POSTGRES_DB:-pmdi}
+
+# ── Help ───────────────────────────────────────────────────────────────────────
+help:
+	@echo ""
+	@echo "  PMDI Developer Makefile"
+	@echo "  ──────────────────────────────────────────────────"
+	@echo "  make up          Start stack (build if needed)"
+	@echo "  make down        Stop and remove containers"
+	@echo "  make restart     Restart all services"
+	@echo "  make logs        Tail logs from all containers"
+	@echo "  make cron        Manually trigger billing scan"
+	@echo "  make migrate     Apply pending DB migrations"
+	@echo "  make autogen MSG='describe change'  Generate migration"
+	@echo "  make shell-be    Shell into backend container"
+	@echo "  make shell-db    psql into PostgreSQL container"
+	@echo ""

+ 29 - 0
backend/Dockerfile

@@ -0,0 +1,29 @@
+# ─────────────────────────────────────────────────────────────────────────────
+#  PMDI Backend — FastAPI / Python 3.11
+# ─────────────────────────────────────────────────────────────────────────────
+FROM python:3.11-slim AS base
+
+# Prevent Python from writing .pyc files and buffer stdout/stderr
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+# Install OS-level deps needed by psycopg2-binary and argon2-cffi
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends \
+        gcc \
+        libpq-dev \
+    && rm -rf /var/lib/apt/lists/*
+
+# Install Python dependencies first (layer cache friendly)
+COPY requirements.txt ./
+RUN pip install --no-cache-dir --upgrade pip \
+    && pip install --no-cache-dir -r requirements.txt
+
+# Copy application source
+COPY app/ ./app/
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]

+ 47 - 0
backend/alembic.ini

@@ -0,0 +1,47 @@
+# alembic.ini — Alembic configuration for PMDI
+# The DATABASE_URL is read from the environment variable at runtime
+# via env.py, so this file does NOT contain credentials.
+
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+
+# Use %(here)s to reference the alembic directory path
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
+timezone = UTC
+truncate_slug_length = 40
+
+# Logging config
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 78 - 0
backend/alembic/env.py

@@ -0,0 +1,78 @@
+"""
+alembic/env.py
+──────────────
+Alembic runtime environment for PMDI.
+
+· Reads DATABASE_URL from the environment (same source as the FastAPI app)
+· Imports all ORM models so Alembic can perform autogenerate comparisons
+· Supports both offline (--sql) and online migration execution
+"""
+from __future__ import annotations
+
+import os
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+# Import the shared declarative Base AND all models so Alembic sees every table
+from app.database import Base  # noqa: F401
+from app.models import (      # noqa: F401
+    AdminUser, Client, EmailTemplate, Invoice, NotificationLog,
+)
+
+# ── Alembic config object ──────────────────────────────────────────────────
+config = context.config
+
+# Set up logging from alembic.ini
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+# Override sqlalchemy.url from the environment so credentials are never
+# stored in alembic.ini or any config file.
+database_url = os.environ.get("DATABASE_URL")
+if database_url:
+    config.set_main_option("sqlalchemy.url", database_url)
+
+target_metadata = Base.metadata
+
+
+# ── Offline mode ───────────────────────────────────────────────────────────
+def run_migrations_offline() -> None:
+    """Generate SQL script without a live database connection."""
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+        compare_server_default=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+# ── Online mode ────────────────────────────────────────────────────────────
+def run_migrations_online() -> None:
+    """Run migrations against a live database connection."""
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            compare_type=True,
+            compare_server_default=True,
+        )
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 29 - 0
backend/alembic/script.py.mako

@@ -0,0 +1,29 @@
+"""Alembic script.py.mako — migration file template."""
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 116 - 0
backend/alembic/versions/0001_initial.py

@@ -0,0 +1,116 @@
+"""Initial schema — create all PMDI tables.
+
+Revision ID: 0001_initial
+Revises: 
+Create Date: 2026-08-31 00:00:00.000000
+
+Creates:
+  · admin_users
+  · clients
+  · email_templates
+  · invoices            (with PostgreSQL ARRAY(Integer) for notification_dates)
+  · notification_logs   (with JSONB context column)
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers
+revision: str           = "0001_initial"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # ── admin_users ────────────────────────────────────────────────────────
+    op.create_table(
+        "admin_users",
+        sa.Column("id",              sa.Integer(),     nullable=False),
+        sa.Column("username",        sa.String(64),    nullable=False),
+        sa.Column("hashed_password", sa.String(256),   nullable=False),
+        sa.Column("totp_secret",     sa.String(64),    nullable=True),
+        sa.Column("mfa_enabled",     sa.Boolean(),     nullable=False, server_default="false"),
+        sa.Column("created_at",      sa.DateTime(),    nullable=False, server_default=sa.func.now()),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("username"),
+    )
+    op.create_index("ix_admin_users_id",       "admin_users", ["id"],       unique=False)
+    op.create_index("ix_admin_users_username", "admin_users", ["username"], unique=True)
+
+    # ── clients ────────────────────────────────────────────────────────────
+    op.create_table(
+        "clients",
+        sa.Column("id",             sa.Integer(),     nullable=False),
+        sa.Column("client_id_name", sa.String(64),    nullable=False),
+        sa.Column("client_name",    sa.String(256),   nullable=False),
+        sa.Column("client_email",   sa.String(256),   nullable=False),
+        sa.Column("created_at",     sa.DateTime(),    nullable=True,  server_default=sa.func.now()),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("client_id_name"),
+    )
+    op.create_index("ix_clients_id", "clients", ["id"], unique=False)
+
+    # ── email_templates ────────────────────────────────────────────────────
+    op.create_table(
+        "email_templates",
+        sa.Column("id",            sa.Integer(),     nullable=False),
+        sa.Column("name",          sa.String(128),   nullable=False),
+        sa.Column("template_type", sa.String(32),    nullable=False),
+        sa.Column("subject",       sa.String(256),   nullable=False),
+        sa.Column("template_body", sa.Text(),        nullable=False),
+        sa.Column("created_at",    sa.DateTime(),    nullable=True,  server_default=sa.func.now()),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_email_templates_id", "email_templates", ["id"], unique=False)
+
+    # ── invoices ───────────────────────────────────────────────────────────
+    op.create_table(
+        "invoices",
+        sa.Column("id",                     sa.Integer(),  nullable=False),
+        sa.Column("invoice_number",         sa.String(64), nullable=False),
+        sa.Column("client_id",              sa.Integer(),  nullable=False),
+        sa.Column("pay_date",               sa.Date(),     nullable=False),
+        sa.Column("template_id",            sa.Integer(),  nullable=False),
+        sa.Column("notification_dates",     postgresql.ARRAY(sa.Integer()), nullable=True),
+        sa.Column("overdue_recurring_days", sa.Integer(),  nullable=False, server_default="0"),
+        sa.Column("paid",                   sa.Boolean(),  nullable=False, server_default="false"),
+        sa.Column("created_at",             sa.DateTime(), nullable=True,  server_default=sa.func.now()),
+        sa.ForeignKeyConstraint(["client_id"],   ["clients.id"],         ondelete="RESTRICT"),
+        sa.ForeignKeyConstraint(["template_id"], ["email_templates.id"], ondelete="RESTRICT"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("invoice_number"),
+    )
+    op.create_index("ix_invoices_id", "invoices", ["id"], unique=False)
+
+    # ── notification_logs ──────────────────────────────────────────────────
+    op.create_table(
+        "notification_logs",
+        sa.Column("id",            sa.Integer(),          nullable=False),
+        sa.Column("invoice_id",    sa.Integer(),          nullable=False),
+        sa.Column("status",        sa.String(64),         nullable=False),
+        sa.Column("sent_at",       sa.DateTime(),         nullable=False, server_default=sa.func.now()),
+        sa.Column("context",       postgresql.JSONB(),    nullable=True),
+        sa.Column("error_message", sa.Text(),             nullable=True),
+        sa.ForeignKeyConstraint(["invoice_id"], ["invoices.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_notification_logs_id", "notification_logs", ["id"], unique=False)
+
+
+def downgrade() -> None:
+    op.drop_index("ix_notification_logs_id", table_name="notification_logs")
+    op.drop_table("notification_logs")
+    op.drop_index("ix_invoices_id", table_name="invoices")
+    op.drop_table("invoices")
+    op.drop_index("ix_email_templates_id", table_name="email_templates")
+    op.drop_table("email_templates")
+    op.drop_index("ix_clients_id", table_name="clients")
+    op.drop_table("clients")
+    op.drop_index("ix_admin_users_username", table_name="admin_users")
+    op.drop_index("ix_admin_users_id", table_name="admin_users")
+    op.drop_table("admin_users")

+ 234 - 0
backend/app/auth.py

@@ -0,0 +1,234 @@
+"""
+app/auth.py
+───────────
+Security layer:
+  · Argon2id password hashing via passlib
+  · HS256 JWT creation / verification (2-hour expiry)
+  · TOTP MFA setup, verification, and activation via pyotp
+  · Login, MFA-verify, MFA-setup, /me endpoints
+  · get_current_user FastAPI dependency (used as auth guard on every route)
+  · seed_admin() helper called at app startup to bootstrap the first admin
+"""
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import Optional
+
+import pyotp
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+from sqlalchemy.orm import Session
+
+from app.config import settings
+from app.database import get_db
+from app.models import (
+    AdminUser,
+    LoginRequest, MFASetupResponse, MFAVerifyRequest,
+    TokenResponse, UserRead,
+)
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Passlib CryptContext — Argon2id
+# ─────────────────────────────────────────────────────────────────────────────
+pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  OAuth2 Bearer token extraction
+# ─────────────────────────────────────────────────────────────────────────────
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Password helpers
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def hash_password(plain: str) -> str:
+    """Hash a plaintext password with Argon2id."""
+    return pwd_context.hash(plain)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+    """Constant-time comparison using Argon2id verify."""
+    return pwd_context.verify(plain, hashed)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  JWT helpers
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+    payload = data.copy()
+    expire  = datetime.utcnow() + (
+        expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+    )
+    payload["exp"] = expire
+    return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+
+
+def _decode_token(token: str) -> dict:
+    try:
+        return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+    except JWTError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Token is invalid or has expired.",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  DB helpers
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def _get_user(db: Session, username: str) -> Optional[AdminUser]:
+    return db.query(AdminUser).filter(AdminUser.username == username).first()
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  FastAPI dependency: authenticated current user
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def get_current_user(
+    token: str = Depends(oauth2_scheme),
+    db:    Session = Depends(get_db),
+) -> AdminUser:
+    payload  = _decode_token(token)
+    username = payload.get("sub")
+    # Reject partial MFA-pending tokens on protected routes
+    if payload.get("mfa_pending"):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="MFA verification required.")
+    if not username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Invalid token payload.")
+    user = _get_user(db, username)
+    if not user:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="User account not found.")
+    return user
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  First-boot seed
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def seed_admin(db: Session) -> None:
+    """Create a default admin account if the admin_users table is empty."""
+    if db.query(AdminUser).first():
+        return
+    admin = AdminUser(
+        username=settings.ADMIN_USERNAME,
+        hashed_password=hash_password(settings.ADMIN_PASSWORD),
+    )
+    db.add(admin)
+    db.commit()
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Auth routes
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@router.post("/login", response_model=TokenResponse, summary="Authenticate with username + password")
+def login(payload: LoginRequest, db: Session = Depends(get_db)):
+    user = _get_user(db, payload.username)
+    if not user or not verify_password(payload.password, user.hashed_password):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Incorrect username or password.",
+        )
+
+    if user.mfa_enabled and user.totp_secret:
+        # Issue a short-lived, MFA-pending partial token; client must call /mfa/verify
+        partial = create_access_token(
+            {"sub": user.username, "mfa_pending": True},
+            expires_delta=timedelta(minutes=5),
+        )
+        return TokenResponse(access_token=partial, mfa_required=True)
+
+    access_token = create_access_token({"sub": user.username})
+    return TokenResponse(access_token=access_token)
+
+
+@router.post("/mfa/verify", response_model=TokenResponse, summary="Complete MFA login with TOTP code")
+def verify_mfa(payload: MFAVerifyRequest, db: Session = Depends(get_db)):
+    user = _get_user(db, payload.username)
+    if not user or not user.totp_secret:
+        raise HTTPException(status_code=400, detail="MFA not configured for this account.")
+
+    totp = pyotp.TOTP(user.totp_secret)
+    if not totp.verify(payload.totp_code, valid_window=1):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code.")
+
+    access_token = create_access_token({"sub": user.username})
+    return TokenResponse(access_token=access_token)
+
+
+@router.post(
+    "/mfa/setup",
+    response_model=MFASetupResponse,
+    summary="Generate a new TOTP secret and QR provisioning URI",
+)
+def setup_mfa(
+    current_user: AdminUser = Depends(get_current_user),
+    db:           Session   = Depends(get_db),
+):
+    """
+    Generates a 32-character base32 TOTP secret and stores it on the account
+    (MFA is NOT enabled yet until /mfa/enable is called with a valid code).
+    Returns the secret and an otpauth:// URI compatible with Google Authenticator.
+    """
+    secret = pyotp.random_base32(length=32)
+    totp   = pyotp.TOTP(secret)
+    qr_uri = totp.provisioning_uri(name=current_user.username, issuer_name="PMDI")
+
+    # Persist the tentative secret; activation requires /mfa/enable
+    current_user.totp_secret = secret
+    db.commit()
+
+    return MFASetupResponse(secret=secret, qr_uri=qr_uri)
+
+
+@router.post("/mfa/enable", summary="Confirm TOTP code and activate MFA on the account")
+def enable_mfa(
+    payload: MFAVerifyRequest,
+    db:      Session = Depends(get_db),
+):
+    user = _get_user(db, payload.username)
+    if not user or not user.totp_secret:
+        raise HTTPException(status_code=400, detail="Call /mfa/setup first.")
+
+    totp = pyotp.TOTP(user.totp_secret)
+    if not totp.verify(payload.totp_code, valid_window=1):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code.")
+
+    user.mfa_enabled = True
+    db.commit()
+    return {"detail": "MFA enabled successfully."}
+
+
+@router.post("/mfa/disable", summary="Disable MFA on the account (requires valid TOTP)")
+def disable_mfa(
+    payload:      MFAVerifyRequest,
+    current_user: AdminUser = Depends(get_current_user),
+    db:           Session   = Depends(get_db),
+):
+    if not current_user.totp_secret:
+        raise HTTPException(status_code=400, detail="MFA not configured.")
+
+    totp = pyotp.TOTP(current_user.totp_secret)
+    if not totp.verify(payload.totp_code, valid_window=1):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code.")
+
+    current_user.mfa_enabled = False
+    current_user.totp_secret = None
+    db.commit()
+    return {"detail": "MFA disabled."}
+
+
+@router.get("/me", response_model=UserRead, summary="Return the current authenticated user")
+def me(current_user: AdminUser = Depends(get_current_user)):
+    return current_user

+ 58 - 0
backend/app/config.py

@@ -0,0 +1,58 @@
+"""
+app/config.py
+─────────────
+Central settings object built from environment variables via pydantic-settings.
+All sensitive values must be supplied via the .env file or container env vars —
+never hard-coded here.
+"""
+from __future__ import annotations
+
+from typing import Optional
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+    model_config = SettingsConfigDict(
+        env_file=".env",
+        env_file_encoding="utf-8",
+        extra="allow",          # allow arbitrary env vars (forwarded to /api/config)
+        case_sensitive=True,
+    )
+
+    # ── Database ──────────────────────────────────────────────────────────────
+    DATABASE_URL: str
+
+    # ── JWT / Authentication ──────────────────────────────────────────────────
+    SECRET_KEY: str
+    ALGORITHM: str = "HS256"
+    ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
+
+    # ── CORS ──────────────────────────────────────────────────────────────────
+    # Comma-separated list of allowed origins, e.g. "http://localhost:3000,https://pmdi.example.com"
+    ALLOWED_ORIGINS: str = "http://localhost:3000"
+
+    # ── Cron endpoint guard ───────────────────────────────────────────────────
+    CRON_SECRET_TOKEN: str
+
+    # ── Seed admin (first-boot only) ──────────────────────────────────────────
+    ADMIN_USERNAME: str = "admin"
+    ADMIN_PASSWORD: str = "changeme"
+
+    # ── Mail sender identity ──────────────────────────────────────────────────
+    MAIL_FROM: str = "noreply@example.com"
+    MAIL_FROM_NAME: str = "PMDI Invoice System"
+
+    # ── Mail Mode A – Authenticated TLS relay ─────────────────────────────────
+    # All three must be set to activate Mode A.
+    MAIL_RELAYHOST: Optional[str] = None            # "smtp.example.com:587"
+    MAIL_RELAYHOST_USERNAME: Optional[str] = None
+    MAIL_RELAYHOST_PASSWORD: Optional[str] = None
+
+    # ── Mail Mode B – Simple container/host proxy relay ───────────────────────
+    # Set to the hostname of the relay container (e.g. "postfix").
+    MAIL_RELAY_HOST: Optional[str] = None
+
+
+# Module-level singleton — import this everywhere instead of re-instantiating.
+settings = Settings()

+ 260 - 0
backend/app/cron.py

@@ -0,0 +1,260 @@
+"""
+app/cron.py
+───────────
+Protected billing-scan daemon triggered via POST /api/cron/run.
+
+Authorization guard:
+  The endpoint rejects any request unless the Authorization header carries
+  a Bearer token that exactly matches the CRON_SECRET_TOKEN env variable.
+  The comparison is done with hmac.compare_digest to prevent timing attacks.
+
+Execution order (per spec):
+  1. Sweep & Recover — retry all NotificationLog rows with status = "Failed"
+  2. Upcoming Reminders — send alerts for invoices where today < pay_date
+     and today is one of the notification_dates intervals
+  3. Overdue Notices — send recurring alerts for invoices where today > pay_date
+     at every multiple of overdue_recurring_days
+"""
+from __future__ import annotations
+
+import hmac
+import logging
+from datetime import date
+from typing import Any, Dict
+
+from fastapi import APIRouter, Depends, Header, HTTPException, status
+from jinja2 import BaseLoader, Environment, TemplateSyntaxError
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.config import settings
+from app.crud import create_notification_log, update_notification_status
+from app.database import get_db
+from app.mail import send_email
+from app.models import (
+    Invoice, NotificationLog,
+    STATUS_DELIVERED, STATUS_RELAY, STATUS_FAILED,
+)
+
+router = APIRouter(prefix="/api/cron", tags=["cron"])
+logger = logging.getLogger(__name__)
+
+# Jinja2 environment — sandboxed, no filesystem loader
+_jinja = Environment(loader=BaseLoader(), autoescape=False)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Helpers
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _verify_cron_token(authorization: str = Header(default=None)) -> None:
+    """Constant-time bearer-token check.  Raises 401/403 on failure."""
+    if not authorization or not authorization.startswith("Bearer "):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Missing or malformed Authorization header.",
+        )
+    token = authorization[len("Bearer "):]
+    if not hmac.compare_digest(
+        token.encode("utf-8"),
+        settings.CRON_SECRET_TOKEN.encode("utf-8"),
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Invalid cron secret token.",
+        )
+
+
+def _render(template_str: str, ctx: Dict[str, Any]) -> str:
+    """Render a Jinja2 template string; fall back to raw string on syntax error."""
+    try:
+        return _jinja.from_string(template_str).render(**ctx)
+    except TemplateSyntaxError as exc:
+        logger.warning("Jinja2 syntax error in template: %s", exc)
+        return template_str
+
+
+def _already_sent_today(db: Session, invoice_id: int, today: date) -> bool:
+    """Return True if a successful (non-Failed) log exists for this invoice today."""
+    return bool(
+        db.query(NotificationLog)
+        .filter(
+            NotificationLog.invoice_id == invoice_id,
+            func.date(NotificationLog.sent_at) == today,
+            NotificationLog.status != STATUS_FAILED,
+        )
+        .first()
+    )
+
+
+def _dispatch(
+    db:         Session,
+    invoice:    Invoice,
+    subject:    str,
+    body:       str,
+    ctx:        Dict[str, Any],
+) -> str:
+    """Send email and create a NotificationLog; returns delivery status."""
+    client = invoice.client
+    try:
+        delivery_status = send_email(client.client_email, subject, body)
+        create_notification_log(db, invoice.id, delivery_status, ctx)
+        return delivery_status
+    except Exception as exc:
+        create_notification_log(db, invoice.id, STATUS_FAILED, ctx, str(exc))
+        return STATUS_FAILED
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  /api/cron/run
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@router.post("/run", summary="Run billing scan (protected — requires CRON_SECRET_TOKEN)")
+def run_cron(
+    db:            Session = Depends(get_db),
+    authorization: str     = Header(default=None),
+):
+    _verify_cron_token(authorization)
+
+    today   = date.today()
+    results: Dict[str, Any] = {
+        "date":           str(today),
+        "recovered":      0,
+        "reminders_sent": 0,
+        "overdue_sent":   0,
+        "errors":         [],
+    }
+
+    # ══════════════════════════════════════════════════════════════════════════
+    #  STEP 1 — Sweep & Recover failed queue
+    # ══════════════════════════════════════════════════════════════════════════
+    failed_logs = (
+        db.query(NotificationLog)
+        .filter(NotificationLog.status == STATUS_FAILED)
+        .all()
+    )
+
+    for log in failed_logs:
+        invoice = db.query(Invoice).filter(Invoice.id == log.invoice_id).first()
+        # Skip ghost references or already-paid invoices
+        if not invoice or invoice.paid:
+            continue
+
+        client   = invoice.client
+        template = invoice.template
+        ctx = {
+            "client_name":    client.client_name,
+            "client_email":   client.client_email,
+            "invoice_number": invoice.invoice_number,
+            "pay_date":       str(invoice.pay_date),
+        }
+
+        try:
+            body    = _render(template.template_body, ctx)
+            subject = _render(template.subject, ctx)
+            new_status = send_email(client.client_email, subject, body)
+            update_notification_status(db, log.id, new_status)
+            results["recovered"] += 1
+        except Exception as exc:
+            logger.error("Recovery failed for log #%d: %s", log.id, exc)
+            update_notification_status(db, log.id, STATUS_FAILED, str(exc))
+            results["errors"].append(f"log#{log.id}: {exc}")
+
+    # ══════════════════════════════════════════════════════════════════════════
+    #  STEP 2 — Upcoming reminders  (today < pay_date, not yet paid)
+    # ══════════════════════════════════════════════════════════════════════════
+    upcoming = (
+        db.query(Invoice)
+        .filter(Invoice.paid.is_(False), Invoice.pay_date > today)
+        .all()
+    )
+
+    for invoice in upcoming:
+        days_until        = (invoice.pay_date - today).days
+        notification_days = invoice.notification_dates or []
+
+        if days_until not in notification_days:
+            continue
+
+        if _already_sent_today(db, invoice.id, today):
+            continue
+
+        client   = invoice.client
+        template = invoice.template
+        ctx = {
+            "client_name":    client.client_name,
+            "client_email":   client.client_email,
+            "invoice_number": invoice.invoice_number,
+            "pay_date":       str(invoice.pay_date),
+            "days_until":     days_until,
+            "trigger":        "reminder",
+        }
+
+        result_status = _dispatch(
+            db, invoice,
+            _render(template.subject, ctx),
+            _render(template.template_body, ctx),
+            ctx,
+        )
+
+        if result_status != STATUS_FAILED:
+            results["reminders_sent"] += 1
+        else:
+            results["errors"].append(f"invoice#{invoice.id} reminder failed")
+
+    # ══════════════════════════════════════════════════════════════════════════
+    #  STEP 3 — Overdue notices  (today > pay_date, recurring interval)
+    # ══════════════════════════════════════════════════════════════════════════
+    overdue = (
+        db.query(Invoice)
+        .filter(
+            Invoice.paid.is_(False),
+            Invoice.pay_date < today,
+            Invoice.overdue_recurring_days > 0,
+        )
+        .all()
+    )
+
+    for invoice in overdue:
+        days_overdue = (today - invoice.pay_date).days
+        interval     = invoice.overdue_recurring_days
+
+        # Fire only on exact multiples of the interval (e.g. every 7 days)
+        if days_overdue == 0 or days_overdue % interval != 0:
+            continue
+
+        if _already_sent_today(db, invoice.id, today):
+            continue
+
+        client   = invoice.client
+        template = invoice.template
+        ctx = {
+            "client_name":    client.client_name,
+            "client_email":   client.client_email,
+            "invoice_number": invoice.invoice_number,
+            "pay_date":       str(invoice.pay_date),
+            "days_overdue":   days_overdue,
+            "trigger":        "overdue",
+        }
+
+        result_status = _dispatch(
+            db, invoice,
+            _render(template.subject, ctx),
+            _render(template.template_body, ctx),
+            ctx,
+        )
+
+        if result_status != STATUS_FAILED:
+            results["overdue_sent"] += 1
+        else:
+            results["errors"].append(f"invoice#{invoice.id} overdue notice failed")
+
+    logger.info(
+        "[CRON] %s → recovered=%d reminders=%d overdue=%d errors=%d",
+        today,
+        results["recovered"],
+        results["reminders_sent"],
+        results["overdue_sent"],
+        len(results["errors"]),
+    )
+    return results

+ 191 - 0
backend/app/crud.py

@@ -0,0 +1,191 @@
+"""
+app/crud.py
+───────────
+Data-access layer: pure SQLAlchemy CRUD functions for every domain entity.
+All functions are intentionally thin — they do no business logic beyond
+the DB operation itself.
+"""
+from __future__ import annotations
+
+from typing import List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.models import (
+    Client,   ClientCreate,   ClientUpdate,
+    EmailTemplate, TemplateCreate, TemplateUpdate,
+    Invoice,  InvoiceCreate,  InvoiceUpdate,
+    NotificationLog,
+)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Clients
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def get_clients(db: Session, skip: int = 0, limit: int = 500) -> List[Client]:
+    return db.query(Client).offset(skip).limit(limit).all()
+
+
+def get_client(db: Session, client_id: int) -> Optional[Client]:
+    return db.query(Client).filter(Client.id == client_id).first()
+
+
+def create_client(db: Session, data: ClientCreate) -> Client:
+    obj = Client(**data.model_dump())
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_client(db: Session, client_id: int, data: ClientUpdate) -> Optional[Client]:
+    obj = get_client(db, client_id)
+    if not obj:
+        return None
+    for field, value in data.model_dump().items():
+        setattr(obj, field, value)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def delete_client(db: Session, client_id: int) -> bool:
+    obj = get_client(db, client_id)
+    if not obj:
+        return False
+    db.delete(obj)
+    db.commit()
+    return True
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Email Templates
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def get_templates(db: Session, skip: int = 0, limit: int = 500) -> List[EmailTemplate]:
+    return db.query(EmailTemplate).offset(skip).limit(limit).all()
+
+
+def get_template(db: Session, template_id: int) -> Optional[EmailTemplate]:
+    return db.query(EmailTemplate).filter(EmailTemplate.id == template_id).first()
+
+
+def create_template(db: Session, data: TemplateCreate) -> EmailTemplate:
+    obj = EmailTemplate(**data.model_dump())
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_template(db: Session, template_id: int, data: TemplateUpdate) -> Optional[EmailTemplate]:
+    obj = get_template(db, template_id)
+    if not obj:
+        return None
+    for field, value in data.model_dump().items():
+        setattr(obj, field, value)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def delete_template(db: Session, template_id: int) -> bool:
+    obj = get_template(db, template_id)
+    if not obj:
+        return False
+    db.delete(obj)
+    db.commit()
+    return True
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Invoices
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def get_invoices(db: Session, skip: int = 0, limit: int = 500) -> List[Invoice]:
+    return db.query(Invoice).offset(skip).limit(limit).all()
+
+
+def get_invoice(db: Session, invoice_id: int) -> Optional[Invoice]:
+    return db.query(Invoice).filter(Invoice.id == invoice_id).first()
+
+
+def create_invoice(db: Session, data: InvoiceCreate) -> Invoice:
+    obj = Invoice(**data.model_dump())
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_invoice(db: Session, invoice_id: int, data: InvoiceUpdate) -> Optional[Invoice]:
+    obj = get_invoice(db, invoice_id)
+    if not obj:
+        return None
+    for field, value in data.model_dump().items():
+        setattr(obj, field, value)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def delete_invoice(db: Session, invoice_id: int) -> bool:
+    obj = get_invoice(db, invoice_id)
+    if not obj:
+        return False
+    db.delete(obj)
+    db.commit()
+    return True
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Notification Logs
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def get_notification_logs(
+    db: Session, skip: int = 0, limit: int = 500
+) -> List[NotificationLog]:
+    return (
+        db.query(NotificationLog)
+        .order_by(NotificationLog.sent_at.desc())
+        .offset(skip)
+        .limit(limit)
+        .all()
+    )
+
+
+def create_notification_log(
+    db: Session,
+    invoice_id:    int,
+    status:        str,
+    context:       Optional[dict] = None,
+    error_message: Optional[str]  = None,
+) -> NotificationLog:
+    obj = NotificationLog(
+        invoice_id=invoice_id,
+        status=status,
+        context=context or {},
+        error_message=error_message,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_notification_status(
+    db:            Session,
+    log_id:        int,
+    status:        str,
+    error_message: Optional[str] = None,
+) -> Optional[NotificationLog]:
+    obj = db.query(NotificationLog).filter(NotificationLog.id == log_id).first()
+    if not obj:
+        return None
+    obj.status = status
+    if error_message is not None:
+        obj.error_message = error_message
+    db.commit()
+    db.refresh(obj)
+    return obj

+ 38 - 0
backend/app/database.py

@@ -0,0 +1,38 @@
+"""
+app/database.py
+───────────────
+SQLAlchemy synchronous engine, session factory, and declarative base.
+"""
+from __future__ import annotations
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+from app.config import settings
+
+engine = create_engine(
+    settings.DATABASE_URL,
+    pool_pre_ping=True,   # recycle stale connections automatically
+    pool_size=10,
+    max_overflow=20,
+)
+
+SessionLocal = sessionmaker(
+    autocommit=False,
+    autoflush=False,
+    bind=engine,
+)
+
+
+class Base(DeclarativeBase):
+    """Shared declarative base – all ORM models inherit from this."""
+    pass
+
+
+def get_db():
+    """FastAPI dependency: yields a scoped DB session and guarantees closure."""
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()

+ 125 - 0
backend/app/mail.py

@@ -0,0 +1,125 @@
+"""
+app/mail.py
+───────────
+Adaptive SMTP delivery engine.
+
+Three mutually-exclusive modes are selected at runtime based on environment:
+
+  Mode A (Authenticated TLS relay)
+    All three MAIL_RELAYHOST + MAIL_RELAYHOST_USERNAME + MAIL_RELAYHOST_PASSWORD
+    must be set.  The backend connects with STARTTLS and authenticates.
+
+  Mode B (Simple container/host proxy relay)
+    Only MAIL_RELAY_HOST is set (hostname, no credentials).
+    Backend opens an unauthenticated SMTP session on port 587 to that host.
+    Designed for a companion boky/postfix container in the same Docker network.
+
+  Direct SMTP (fallback)
+    No relay variables present.  Backend attempts delivery via localhost:25
+    (or any MTA listening locally).
+"""
+from __future__ import annotations
+
+import logging
+import smtplib
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from enum import Enum
+
+from app.config import settings
+from app.models import STATUS_DELIVERED, STATUS_FAILED, STATUS_RELAY
+
+logger = logging.getLogger(__name__)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Mode detection
+# ─────────────────────────────────────────────────────────────────────────────
+
+class MailMode(str, Enum):
+    MODE_A = "authenticated_relay"
+    MODE_B = "proxy_relay"
+    DIRECT = "direct_smtp"
+
+
+def _detect_mode() -> MailMode:
+    if (
+        settings.MAIL_RELAYHOST
+        and settings.MAIL_RELAYHOST_USERNAME
+        and settings.MAIL_RELAYHOST_PASSWORD
+    ):
+        return MailMode.MODE_A
+    if settings.MAIL_RELAY_HOST:
+        return MailMode.MODE_B
+    return MailMode.DIRECT
+
+
+def _build_message(to_email: str, subject: str, body: str) -> MIMEMultipart:
+    msg = MIMEMultipart("alternative")
+    msg["Subject"] = subject
+    msg["From"]    = f"{settings.MAIL_FROM_NAME} <{settings.MAIL_FROM}>"
+    msg["To"]      = to_email
+    msg.attach(MIMEText(body, "plain", "utf-8"))
+    return msg
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Public send function
+# ─────────────────────────────────────────────────────────────────────────────
+
+def send_email(to_email: str, subject: str, body: str) -> str:
+    """
+    Deliver an email to *to_email* using the configured strategy.
+
+    Returns one of the STATUS_* constants on success.
+    Raises on failure so the caller can log STATUS_FAILED.
+    """
+    mode = _detect_mode()
+    msg  = _build_message(to_email, subject, body)
+    raw  = msg.as_string()
+
+    try:
+        if mode == MailMode.MODE_A:
+            # ── Authenticated TLS relay ──────────────────────────────────────
+            relay = settings.MAIL_RELAYHOST
+            if ":" in relay:
+                host, port_str = relay.rsplit(":", 1)
+                port = int(port_str)
+            else:
+                host, port = relay, 587
+
+            with smtplib.SMTP(host, port, timeout=20) as srv:
+                srv.ehlo()
+                srv.starttls()
+                srv.ehlo()
+                srv.login(settings.MAIL_RELAYHOST_USERNAME, settings.MAIL_RELAYHOST_PASSWORD)
+                srv.sendmail(settings.MAIL_FROM, [to_email], raw)
+
+            logger.info("[MAIL MODE-A] → %s via %s:%d", to_email, host, port)
+            return STATUS_DELIVERED
+
+        elif mode == MailMode.MODE_B:
+            # ── Unauthenticated container proxy relay ────────────────────────
+            host = settings.MAIL_RELAY_HOST
+            with smtplib.SMTP(host, 587, timeout=20) as srv:
+                srv.ehlo()
+                srv.sendmail(settings.MAIL_FROM, [to_email], raw)
+
+            logger.info("[MAIL MODE-B] → %s via %s:587", to_email, host)
+            return STATUS_RELAY
+
+        else:
+            # ── Direct localhost:25 delivery ─────────────────────────────────
+            with smtplib.SMTP("localhost", 25, timeout=20) as srv:
+                srv.sendmail(settings.MAIL_FROM, [to_email], raw)
+
+            logger.info("[MAIL DIRECT] → %s via localhost:25", to_email)
+            return STATUS_DELIVERED
+
+    except Exception as exc:
+        logger.error(
+            "[MAIL ERROR] Failed to deliver to %s (mode=%s): %s",
+            to_email, mode.value, exc,
+            exc_info=True,
+        )
+        raise

+ 238 - 0
backend/app/main.py

@@ -0,0 +1,238 @@
+"""
+app/main.py
+───────────
+FastAPI application bootstrap:
+  · CORS middleware (origin whitelist from env)
+  · Lifespan handler: DDL table creation + admin seed
+  · Auth and cron routers (no auth dependency on their own paths)
+  · Protected API router for all CRUD + config endpoints
+"""
+from __future__ import annotations
+
+import logging
+import os
+from contextlib import asynccontextmanager
+from typing import List
+
+from fastapi import APIRouter, Depends, FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from sqlalchemy.orm import Session
+
+from app.auth import get_current_user, router as auth_router, seed_admin
+from app.config import settings
+from app.cron import router as cron_router
+from app.crud import (
+    create_client, create_invoice, create_template,
+    delete_client, delete_invoice, delete_template,
+    get_clients, get_invoices, get_notification_logs, get_templates,
+    update_client, update_invoice, update_template,
+)
+from app.database import Base, engine, get_db
+from app.models import (
+    ClientCreate, ClientRead, ClientUpdate,
+    ConfigEntry,
+    InvoiceCreate, InvoiceRead, InvoiceUpdate,
+    NotificationLogRead,
+    TemplateCreate, TemplateRead, TemplateUpdate,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s  %(levelname)-8s  %(name)s  %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Config endpoint — sensitive-value masking
+# ─────────────────────────────────────────────────────────────────────────────
+_SENSITIVE_TOKENS = {"PASSWORD", "SECRET", "TOKEN", "KEY", "DATABASE_URL", "DSN", "PWD", "PASS"}
+
+
+def _is_sensitive(key: str) -> bool:
+    ku = key.upper()
+    return any(tok in ku for tok in _SENSITIVE_TOKENS)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Lifespan
+# ─────────────────────────────────────────────────────────────────────────────
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # DDL: create all tables if they don't exist yet
+    Base.metadata.create_all(bind=engine)
+
+    # Seed the default admin account on first boot
+    db = next(get_db())
+    try:
+        seed_admin(db)
+    finally:
+        db.close()
+
+    logger.info("✅  PMDI backend ready.")
+    yield
+    logger.info("🛑  PMDI backend shutting down.")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Application
+# ─────────────────────────────────────────────────────────────────────────────
+app = FastAPI(
+    title="PMDI – Pay My Damn Invoice",
+    description="Internal invoice notification dashboard API.",
+    version="1.0.0",
+    docs_url="/api/docs",
+    redoc_url="/api/redoc",
+    openapi_url="/api/openapi.json",
+    lifespan=lifespan,
+)
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  CORS middleware — strict origin whitelist
+# ─────────────────────────────────────────────────────────────────────────────
+_origins = [o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()]
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=_origins,
+    allow_credentials=True,
+    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+    allow_headers=["Authorization", "Content-Type"],
+    expose_headers=["Content-Length"],
+)
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Unauthenticated routers
+# ─────────────────────────────────────────────────────────────────────────────
+app.include_router(auth_router)   # /api/auth/* — handles its own auth internally
+app.include_router(cron_router)   # /api/cron/* — guarded by CRON_SECRET_TOKEN
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  Protected API router (requires valid JWT for every sub-route)
+# ─────────────────────────────────────────────────────────────────────────────
+protected = APIRouter(
+    prefix="/api",
+    dependencies=[Depends(get_current_user)],
+)
+
+
+# ── Health ────────────────────────────────────────────────────────────────────
+@app.get("/api/health", tags=["system"])
+def health():
+    return {"status": "ok", "service": "pmdi-backend", "version": "1.0.0"}
+
+
+# ── Config ────────────────────────────────────────────────────────────────────
+@protected.get("/config", response_model=List[ConfigEntry], tags=["config"])
+def get_config():
+    """
+    Return all container environment variables.
+    Values whose key names contain PASSWORD / SECRET / TOKEN / KEY / DATABASE_URL
+    are automatically replaced with '••••••••'.
+    """
+    entries = []
+    for key, value in sorted(os.environ.items()):
+        masked = _is_sensitive(key)
+        entries.append(ConfigEntry(
+            key=key,
+            value="••••••••" if masked else value,
+            masked=masked,
+        ))
+    return entries
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+#  Clients
+# ══════════════════════════════════════════════════════════════════════════════
+
+@protected.get("/clients", response_model=List[ClientRead], tags=["clients"])
+def list_clients(db: Session = Depends(get_db)):
+    return get_clients(db)
+
+
+@protected.post("/clients", response_model=ClientRead, status_code=201, tags=["clients"])
+def add_client(data: ClientCreate, db: Session = Depends(get_db)):
+    return create_client(db, data)
+
+
+@protected.put("/clients/{client_id}", response_model=ClientRead, tags=["clients"])
+def edit_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_db)):
+    obj = update_client(db, client_id, data)
+    if not obj:
+        raise HTTPException(404, "Client not found.")
+    return obj
+
+
+@protected.delete("/clients/{client_id}", status_code=204, tags=["clients"])
+def remove_client(client_id: int, db: Session = Depends(get_db)):
+    if not delete_client(db, client_id):
+        raise HTTPException(404, "Client not found.")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+#  Email Templates
+# ══════════════════════════════════════════════════════════════════════════════
+
+@protected.get("/templates", response_model=List[TemplateRead], tags=["templates"])
+def list_templates(db: Session = Depends(get_db)):
+    return get_templates(db)
+
+
+@protected.post("/templates", response_model=TemplateRead, status_code=201, tags=["templates"])
+def add_template(data: TemplateCreate, db: Session = Depends(get_db)):
+    return create_template(db, data)
+
+
+@protected.put("/templates/{template_id}", response_model=TemplateRead, tags=["templates"])
+def edit_template(template_id: int, data: TemplateUpdate, db: Session = Depends(get_db)):
+    obj = update_template(db, template_id, data)
+    if not obj:
+        raise HTTPException(404, "Template not found.")
+    return obj
+
+
+@protected.delete("/templates/{template_id}", status_code=204, tags=["templates"])
+def remove_template(template_id: int, db: Session = Depends(get_db)):
+    if not delete_template(db, template_id):
+        raise HTTPException(404, "Template not found.")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+#  Invoices
+# ══════════════════════════════════════════════════════════════════════════════
+
+@protected.get("/invoices", response_model=List[InvoiceRead], tags=["invoices"])
+def list_invoices(db: Session = Depends(get_db)):
+    return get_invoices(db)
+
+
+@protected.post("/invoices", response_model=InvoiceRead, status_code=201, tags=["invoices"])
+def add_invoice(data: InvoiceCreate, db: Session = Depends(get_db)):
+    return create_invoice(db, data)
+
+
+@protected.put("/invoices/{invoice_id}", response_model=InvoiceRead, tags=["invoices"])
+def edit_invoice(invoice_id: int, data: InvoiceUpdate, db: Session = Depends(get_db)):
+    obj = update_invoice(db, invoice_id, data)
+    if not obj:
+        raise HTTPException(404, "Invoice not found.")
+    return obj
+
+
+@protected.delete("/invoices/{invoice_id}", status_code=204, tags=["invoices"])
+def remove_invoice(invoice_id: int, db: Session = Depends(get_db)):
+    if not delete_invoice(db, invoice_id):
+        raise HTTPException(404, "Invoice not found.")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+#  Notification Logs (read-only)
+# ══════════════════════════════════════════════════════════════════════════════
+
+@protected.get("/notifications", response_model=List[NotificationLogRead], tags=["notifications"])
+def list_notifications(db: Session = Depends(get_db)):
+    return get_notification_logs(db)
+
+
+# Mount the protected router
+app.include_router(protected)

+ 241 - 0
backend/app/models.py

@@ -0,0 +1,241 @@
+"""
+app/models.py
+─────────────
+SQLAlchemy ORM models (database schema) and Pydantic v2 schemas
+(request/response validation) for every PMDI domain entity.
+"""
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Any, Dict, List, Optional
+
+from pydantic import BaseModel, ConfigDict, EmailStr
+from sqlalchemy import (
+    Boolean, Column, Date, DateTime, ForeignKey,
+    Integer, String, Text,
+)
+from sqlalchemy.dialects.postgresql import ARRAY, JSONB
+from sqlalchemy.orm import relationship
+
+from app.database import Base
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Notification delivery-status constants
+# ═══════════════════════════════════════════════════════════════════════════════
+
+STATUS_DELIVERED = "Delivered from Server"
+STATUS_RELAY     = "Sent to Mail Relay"
+STATUS_FAILED    = "Failed"
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  SQLAlchemy ORM Models
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class AdminUser(Base):
+    """Internal administrator account with optional TOTP MFA."""
+    __tablename__ = "admin_users"
+
+    id              = Column(Integer, primary_key=True, index=True)
+    username        = Column(String(64),  unique=True, nullable=False, index=True)
+    hashed_password = Column(String(256), nullable=False)
+    totp_secret     = Column(String(64),  nullable=True)   # base32 TOTP secret
+    mfa_enabled     = Column(Boolean, default=False, nullable=False)
+    created_at      = Column(DateTime, default=datetime.utcnow, nullable=False)
+
+
+class Client(Base):
+    """Customer record referenced by invoices."""
+    __tablename__ = "clients"
+
+    id             = Column(Integer, primary_key=True, index=True)
+    client_id_name = Column(String(64),  unique=True, nullable=False)  # short slug
+    client_name    = Column(String(256), nullable=False)
+    client_email   = Column(String(256), nullable=False)
+    created_at     = Column(DateTime, default=datetime.utcnow)
+
+    invoices = relationship("Invoice", back_populates="client")
+
+
+class EmailTemplate(Base):
+    """Jinja2 email template with a human-readable name, subject, and body."""
+    __tablename__ = "email_templates"
+
+    id            = Column(Integer, primary_key=True, index=True)
+    name          = Column(String(128), nullable=False)          # dropdown label
+    template_type = Column(String(32),  nullable=False)          # "Notification" | "Overdue"
+    subject       = Column(String(256), nullable=False)          # Jinja2 subject line
+    template_body = Column(Text, nullable=False)                 # Jinja2 body
+    created_at    = Column(DateTime, default=datetime.utcnow)
+
+    invoices = relationship("Invoice", back_populates="template")
+
+
+class Invoice(Base):
+    """Open receivable with scheduling metadata for automated reminders."""
+    __tablename__ = "invoices"
+
+    id                      = Column(Integer, primary_key=True, index=True)
+    invoice_number          = Column(String(64), nullable=False, unique=True)
+    client_id               = Column(Integer, ForeignKey("clients.id",  ondelete="RESTRICT"), nullable=False)
+    pay_date                = Column(Date, nullable=False)
+    template_id             = Column(Integer, ForeignKey("email_templates.id", ondelete="RESTRICT"), nullable=False)
+    # Days-before-pay_date triggers, e.g. [30, 14, 7, 1]
+    notification_dates      = Column(ARRAY(Integer), nullable=True, default=[])
+    # Send recurring overdue alert every N days past due (0 = disabled)
+    overdue_recurring_days  = Column(Integer, default=0, nullable=False)
+    paid                    = Column(Boolean, default=False, nullable=False)
+    created_at              = Column(DateTime, default=datetime.utcnow)
+
+    client   = relationship("Client",        back_populates="invoices")
+    template = relationship("EmailTemplate", back_populates="invoices")
+    logs     = relationship(
+        "NotificationLog",
+        back_populates="invoice",
+        cascade="all, delete-orphan",
+    )
+
+
+class NotificationLog(Base):
+    """Immutable audit record for every email dispatch attempt."""
+    __tablename__ = "notification_logs"
+
+    id            = Column(Integer, primary_key=True, index=True)
+    invoice_id    = Column(Integer, ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False)
+    status        = Column(String(64), nullable=False)   # STATUS_* constants
+    sent_at       = Column(DateTime, default=datetime.utcnow, nullable=False)
+    context       = Column(JSONB, nullable=True)          # runtime snapshot for UI display
+    error_message = Column(Text, nullable=True)
+
+    invoice = relationship("Invoice", back_populates="logs")
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Pydantic v2 Schemas
+# ═══════════════════════════════════════════════════════════════════════════════
+
+_orm = ConfigDict(from_attributes=True)
+
+
+# ── Auth / JWT ─────────────────────────────────────────────────────────────────
+
+class LoginRequest(BaseModel):
+    username: str
+    password: str
+
+
+class TokenResponse(BaseModel):
+    access_token: str
+    token_type:   str  = "bearer"
+    mfa_required: bool = False
+
+
+class MFAVerifyRequest(BaseModel):
+    username:  str
+    totp_code: str
+
+
+class MFASetupResponse(BaseModel):
+    secret: str
+    qr_uri: str
+
+
+class UserRead(BaseModel):
+    model_config = _orm
+    id:          int
+    username:    str
+    mfa_enabled: bool
+
+
+# ── Client ─────────────────────────────────────────────────────────────────────
+
+class ClientCreate(BaseModel):
+    client_id_name: str
+    client_name:    str
+    client_email:   EmailStr
+
+
+class ClientUpdate(ClientCreate):
+    pass
+
+
+class ClientRead(BaseModel):
+    model_config = _orm
+    id:             int
+    client_id_name: str
+    client_name:    str
+    client_email:   str
+
+
+# ── EmailTemplate ──────────────────────────────────────────────────────────────
+
+class TemplateCreate(BaseModel):
+    name:          str
+    template_type: str    # "Notification" | "Overdue"
+    subject:       str
+    template_body: str
+
+
+class TemplateUpdate(TemplateCreate):
+    pass
+
+
+class TemplateRead(BaseModel):
+    model_config = _orm
+    id:            int
+    name:          str
+    template_type: str
+    subject:       str
+    template_body: str
+
+
+# ── Invoice ────────────────────────────────────────────────────────────────────
+
+class InvoiceCreate(BaseModel):
+    invoice_number:         str
+    client_id:              int
+    pay_date:               date
+    template_id:            int
+    notification_dates:     Optional[List[int]] = []
+    overdue_recurring_days: int = 0
+    paid:                   bool = False
+
+
+class InvoiceUpdate(InvoiceCreate):
+    pass
+
+
+class InvoiceRead(BaseModel):
+    model_config = _orm
+    id:                     int
+    invoice_number:         str
+    client_id:              int
+    pay_date:               date
+    template_id:            int
+    notification_dates:     Optional[List[int]] = []
+    overdue_recurring_days: int
+    paid:                   bool
+    # Eagerly joined for the dashboard display
+    client:   Optional[ClientRead]   = None
+    template: Optional[TemplateRead] = None
+
+
+# ── Notification Log ───────────────────────────────────────────────────────────
+
+class NotificationLogRead(BaseModel):
+    model_config = _orm
+    id:            int
+    invoice_id:    int
+    status:        str
+    sent_at:       datetime
+    context:       Optional[Dict[str, Any]] = None
+    error_message: Optional[str] = None
+
+
+# ── Config ─────────────────────────────────────────────────────────────────────
+
+class ConfigEntry(BaseModel):
+    key:    str
+    value:  str
+    masked: bool

+ 29 - 0
backend/requirements.txt

@@ -0,0 +1,29 @@
+# ─────────────────────────────────────────────────────────────────────────────
+#  PMDI Backend – Python dependencies
+# ─────────────────────────────────────────────────────────────────────────────
+
+# Web framework
+fastapi==0.111.0
+uvicorn[standard]==0.29.0
+python-multipart==0.0.9        # form / file upload parsing
+
+# ORM + database
+sqlalchemy==2.0.30
+psycopg2-binary==2.9.9         # PostgreSQL sync driver
+
+# Settings / validation
+pydantic==2.7.1
+pydantic-settings==2.2.1
+email-validator==2.1.2         # EmailStr support in Pydantic
+
+# Security: JWT + Argon2id password hashing + TOTP MFA
+python-jose[cryptography]==3.3.0
+passlib[argon2]==1.7.4
+argon2-cffi==23.1.0            # Argon2id C backend required by passlib[argon2]
+pyotp==2.9.0                   # TOTP / Google Authenticator
+
+# Template rendering
+jinja2==3.1.4
+
+# DB migrations (optional but included for production use)
+alembic==1.13.1

+ 94 - 0
docker-compose.yml

@@ -0,0 +1,94 @@
+version: "3.9"
+
+# ─────────────────────────────────────────────────────────────────────────────
+#  PMDI – Pay My Damn Invoice
+#  Multi-container Docker Compose orchestration
+# ─────────────────────────────────────────────────────────────────────────────
+
+services:
+
+  # ── PostgreSQL 15 ──────────────────────────────────────────────────────────
+  db:
+    image: postgres:15-alpine
+    container_name: pmdi_db
+    restart: unless-stopped
+    environment:
+      POSTGRES_DB:       ${POSTGRES_DB:-pmdi}
+      POSTGRES_USER:     ${POSTGRES_USER:-pmdi}
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+    networks:
+      - pmdi_net
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pmdi} -d ${POSTGRES_DB:-pmdi}"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+      start_period: 15s
+
+  # ── FastAPI Backend ────────────────────────────────────────────────────────
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    container_name: pmdi_backend
+    restart: unless-stopped
+    env_file:
+      - .env
+    environment:
+      # Override DATABASE_URL so it always points to the db container
+      DATABASE_URL: postgresql://${POSTGRES_USER:-pmdi}:${POSTGRES_PASSWORD:-changeme}@db:5432/${POSTGRES_DB:-pmdi}
+    depends_on:
+      db:
+        condition: service_healthy
+    ports:
+      - "8000:8000"
+    networks:
+      - pmdi_net
+
+  # ── Vue 3 Frontend ─────────────────────────────────────────────────────────
+  frontend:
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+    container_name: pmdi_frontend
+    restart: unless-stopped
+    environment:
+      # Vite dev-server proxy target (must resolve inside the Docker network)
+      VITE_API_TARGET: http://backend:8000
+    ports:
+      - "3000:3000"
+    networks:
+      - pmdi_net
+    depends_on:
+      - backend
+
+  # ─── Optional: Postfix Mail Relay ─────────────────────────────────────────
+  # Uncomment the block below to start an in-network mail relay (Mode B).
+  # Then add  MAIL_RELAY_HOST=postfix  to your .env file so the backend
+  # routes outbound mail through this container.
+  #
+  # postfix:
+  #   image: boky/postfix:latest
+  #   container_name: pmdi_postfix
+  #   restart: unless-stopped
+  #   environment:
+  #     ALLOWED_SENDER_DOMAINS: "${MAIL_SENDER_DOMAIN:-example.com}"
+  #     # Optional: forward to an upstream relay (e.g. Gmail SMTP)
+  #     # RELAYHOST:          "[smtp.gmail.com]:587"
+  #     # RELAYHOST_USERNAME: "user@gmail.com"
+  #     # RELAYHOST_PASSWORD: "app-password"
+  #   networks:
+  #     - pmdi_net
+  # ──────────────────────────────────────────────────────────────────────────
+
+# ── Volumes ────────────────────────────────────────────────────────────────
+volumes:
+  postgres_data:
+    driver: local
+
+# ── Networks ───────────────────────────────────────────────────────────────
+networks:
+  pmdi_net:
+    driver: bridge

+ 18 - 0
frontend/Dockerfile

@@ -0,0 +1,18 @@
+# ─────────────────────────────────────────────────────────────────────────────
+#  PMDI Frontend — Vue 3 / Vite dev server
+# ─────────────────────────────────────────────────────────────────────────────
+FROM node:20-alpine
+
+WORKDIR /app
+
+# Install dependencies in a separate layer for cache efficiency
+COPY package*.json ./
+RUN npm install
+
+# Copy application source
+COPY . .
+
+EXPOSE 3000
+
+# --host 0.0.0.0 makes Vite reachable from outside the container
+CMD ["npm", "run", "dev"]

+ 22 - 0
frontend/index.html

@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en" class="dark">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="PMDI – Pay My Damn Invoice. Internal invoice notification management dashboard." />
+    <meta name="robots" content="noindex, nofollow" />
+    <title>PMDI – Pay My Damn Invoice</title>
+
+    <!-- Inter + JetBrains Mono from Google Fonts -->
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link
+      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
+      rel="stylesheet"
+    />
+  </head>
+  <body class="bg-slate-950 text-slate-100 antialiased">
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 25 - 0
frontend/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "pmdi-frontend",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev":     "vite --host 0.0.0.0 --port 3000",
+    "build":   "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue":         "^3.4.27",
+    "vue-router":  "^4.3.3",
+    "pinia":       "^2.1.7",
+    "axios":       "^1.7.2",
+    "@vueuse/core": "^10.10.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.4",
+    "vite":               "^5.2.12",
+    "tailwindcss":        "^3.4.4",
+    "postcss":            "^8.4.38",
+    "autoprefixer":       "^10.4.19"
+  }
+}

+ 6 - 0
frontend/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss:  {},
+    autoprefixer: {},
+  },
+}

+ 7 - 0
frontend/src/App.vue

@@ -0,0 +1,7 @@
+<script setup>
+import { RouterView } from 'vue-router'
+</script>
+
+<template>
+  <RouterView />
+</template>

+ 39 - 0
frontend/src/api/index.js

@@ -0,0 +1,39 @@
+/**
+ * api/index.js
+ * Axios instance pre-configured for the PMDI backend.
+ *
+ * · Base URL: /api  (proxied by Vite to the FastAPI container)
+ * · Request interceptor: attaches JWT Bearer header from Pinia auth store
+ * · Response interceptor: redirects to /login on 401 (expired/invalid token)
+ */
+import axios from 'axios'
+import { useAuthStore } from '@/store/auth.js'
+
+const api = axios.create({
+  baseURL: '/api',
+  timeout: 20_000,
+})
+
+// Attach token to every outgoing request
+api.interceptors.request.use((config) => {
+  const auth = useAuthStore()
+  if (auth.token) {
+    config.headers.Authorization = `Bearer ${auth.token}`
+  }
+  return config
+})
+
+// Auto-logout on 401
+api.interceptors.response.use(
+  (res) => res,
+  (err) => {
+    if (err.response?.status === 401) {
+      const auth = useAuthStore()
+      auth.logout()
+      window.location.href = '/login'
+    }
+    return Promise.reject(err)
+  }
+)
+
+export default api

+ 229 - 0
frontend/src/components/Dashboard.vue

@@ -0,0 +1,229 @@
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useAuthStore } from '@/store/auth.js'
+import { useNotificationsStore } from '@/store/notifications.js'
+
+// Tab panel components (lazy-loaded per tab activation)
+import ConfigTab        from './Tabs/ConfigTab.vue'
+import ClientsTab       from './Tabs/ClientsTab.vue'
+import TemplatesTab     from './Tabs/TemplatesTab.vue'
+import InvoicesTab      from './Tabs/InvoicesTab.vue'
+import NotificationsTab from './Tabs/NotificationsTab.vue'
+
+const auth          = useAuthStore()
+const notifications = useNotificationsStore()
+const router        = useRouter()
+const sidebarOpen   = ref(false)
+const activeTab     = ref('invoices')
+
+const tabs = [
+  {
+    id: 'invoices', label: 'Invoices',
+    path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
+  },
+  {
+    id: 'clients', label: 'Clients',
+    path: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z',
+  },
+  {
+    id: 'templates', label: 'Templates',
+    path: 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z',
+  },
+  {
+    id: 'notifications', label: 'Notifications',
+    path: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
+  },
+  {
+    id: 'config', label: 'Configuration',
+    path: 'M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4',
+  },
+]
+
+const tabComponents = {
+  config:        ConfigTab,
+  clients:       ClientsTab,
+  templates:     TemplatesTab,
+  invoices:      InvoicesTab,
+  notifications: NotificationsTab,
+}
+
+const activeLabel = computed(() => tabs.find(t => t.id === activeTab.value)?.label ?? '')
+const userInitial = computed(() => (auth.user?.username?.[0] ?? 'A').toUpperCase())
+
+onMounted(() => {
+  auth.fetchUser()
+  notifications.fetchAll()
+})
+
+function setTab(id) {
+  activeTab.value   = id
+  sidebarOpen.value = false
+}
+
+function logout() {
+  auth.logout()
+  router.push('/login')
+}
+</script>
+
+<template>
+  <div class="flex h-screen bg-slate-950 overflow-hidden">
+
+    <!-- ── Mobile overlay ──────────────────────────────────────────────────── -->
+    <Transition name="overlay">
+      <div
+        v-if="sidebarOpen"
+        class="fixed inset-0 z-20 bg-black/60 backdrop-blur-sm lg:hidden"
+        @click="sidebarOpen = false"
+      />
+    </Transition>
+
+    <!-- ══════════════════════════════════════════════════════════════════════
+         Sidebar
+    ══════════════════════════════════════════════════════════════════════ -->
+    <aside :class="[
+      'fixed lg:static inset-y-0 left-0 z-30 flex flex-col w-60 bg-slate-900 border-r border-slate-800 transition-transform duration-300 ease-in-out flex-shrink-0',
+      sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
+    ]">
+
+      <!-- Logo -->
+      <div class="flex items-center gap-3 px-4 py-5 border-b border-slate-800">
+        <div class="w-9 h-9 bg-gradient-to-br from-brand-500 to-violet-600 rounded-xl flex items-center justify-center shadow-lg shadow-brand-500/20 flex-shrink-0">
+          <svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+              d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+          </svg>
+        </div>
+        <div class="min-w-0">
+          <h1 class="text-sm font-bold text-white leading-none">PMDI</h1>
+          <p class="text-[10px] text-slate-500 mt-0.5 truncate">Invoice Notification System</p>
+        </div>
+      </div>
+
+      <!-- Navigation links -->
+      <nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
+        <button
+          v-for="tab in tabs"
+          :key="tab.id"
+          :id="`nav-${tab.id}`"
+          @click="setTab(tab.id)"
+          :class="[
+            'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 text-left',
+            activeTab === tab.id
+              ? 'bg-gradient-to-r from-brand-600/80 to-violet-600/80 text-white shadow-lg shadow-brand-600/20'
+              : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800',
+          ]"
+        >
+          <svg class="w-[18px] h-[18px] flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" :d="tab.path" />
+          </svg>
+          <span class="truncate flex-1">{{ tab.label }}</span>
+          <!-- Failed delivery badge on notifications tab -->
+          <span
+            v-if="tab.id === 'notifications' && notifications.hasFailed"
+            class="ml-auto px-1.5 py-0.5 bg-red-500 text-white rounded-full text-[10px] font-bold leading-none flex-shrink-0"
+          >{{ notifications.failed }}</span>
+          <span v-else-if="activeTab === tab.id" class="ml-auto w-1.5 h-1.5 rounded-full bg-white/60 flex-shrink-0" />
+        </button>
+      </nav>
+
+      <!-- User footer -->
+      <div class="p-3 border-t border-slate-800 space-y-1">
+        <!-- User info row -->
+        <div class="flex items-center gap-3 px-2 py-2 rounded-lg">
+          <div class="w-7 h-7 rounded-lg bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
+            {{ userInitial }}
+          </div>
+          <div class="flex-1 min-w-0">
+            <p class="text-xs font-medium text-slate-200 truncate">{{ auth.user?.username ?? 'Admin' }}</p>
+            <p class="text-[10px] text-slate-500">{{ auth.user?.mfa_enabled ? 'MFA Active' : 'Administrator' }}</p>
+          </div>
+        </div>
+        <!-- MFA Setup link -->
+        <button
+          id="mfa-setup-link"
+          @click="router.push('/mfa-setup')"
+          class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-slate-500 hover:text-brand-400 hover:bg-brand-500/10 transition-all duration-200"
+        >
+          <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+              d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+          </svg>
+          {{ auth.user?.mfa_enabled ? 'Manage MFA' : 'Enable MFA' }}
+        </button>
+        <!-- Logout -->
+        <button
+          id="logout-btn"
+          @click="logout"
+          class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-all duration-200"
+        >
+          <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
+          </svg>
+          Sign out
+        </button>
+      </div>
+    </aside>
+
+    <!-- ══════════════════════════════════════════════════════════════════════
+         Main content
+    ══════════════════════════════════════════════════════════════════════ -->
+    <div class="flex-1 flex flex-col min-w-0 overflow-hidden">
+
+      <!-- Top bar -->
+      <header class="flex items-center gap-4 px-5 py-3.5 bg-slate-900/60 border-b border-slate-800 backdrop-blur-md flex-shrink-0">
+        <!-- Mobile menu toggle -->
+        <button
+          id="mobile-menu-btn"
+          @click="sidebarOpen = !sidebarOpen"
+          class="lg:hidden btn-icon"
+          aria-label="Toggle sidebar"
+        >
+          <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
+          </svg>
+        </button>
+
+        <!-- Breadcrumb -->
+        <div class="flex items-center gap-2 text-xs text-slate-500">
+          <span>Dashboard</span>
+          <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
+          </svg>
+          <span class="text-slate-300 font-medium">{{ activeLabel }}</span>
+        </div>
+
+        <div class="flex-1" />
+
+        <!-- Live indicator -->
+        <div class="flex items-center gap-2">
+          <span class="relative flex h-2 w-2">
+            <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-50"></span>
+            <span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
+          </span>
+          <span class="text-xs text-slate-500 hidden sm:block">System Active</span>
+        </div>
+      </header>
+
+      <!-- Tab content area -->
+      <main class="flex-1 overflow-y-auto p-5 lg:p-6">
+        <Transition name="tab" mode="out-in">
+          <component :is="tabComponents[activeTab]" :key="activeTab" />
+        </Transition>
+      </main>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* Sidebar overlay */
+.overlay-enter-active, .overlay-leave-active { transition: opacity 0.25s ease; }
+.overlay-enter-from, .overlay-leave-to { opacity: 0; }
+
+/* Tab switch animation */
+.tab-enter-active { transition: all 0.2s ease-out; }
+.tab-leave-active { transition: all 0.15s ease-in; }
+.tab-enter-from   { opacity: 0; transform: translateY(10px); }
+.tab-leave-to     { opacity: 0; transform: translateY(-6px); }
+</style>

+ 236 - 0
frontend/src/components/Tabs/ClientsTab.vue

@@ -0,0 +1,236 @@
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useClientsStore } from '@/store/clients.js'
+import Modal from '@/components/ui/Modal.vue'
+import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
+
+// ── Store ─────────────────────────────────────────────────────────────────
+const store = useClientsStore()
+
+const showModal   = ref(false)
+const showConfirm = ref(false)
+const editingId   = ref(null)
+const deleteId    = ref(null)
+const saving      = ref(false)
+
+const EMPTY_FORM = () => ({ client_id_name: '', client_name: '', client_email: '' })
+const form = reactive(EMPTY_FORM())
+const formError = ref('')
+
+// Template tags available for use in email templates
+const TEMPLATE_TAGS = [
+  { tag: '{{client_name}}',    desc: 'Full client name' },
+  { tag: '{{client_email}}',   desc: 'Client email address' },
+  { tag: '{{invoice_number}}', desc: 'Invoice reference number' },
+  { tag: '{{pay_date}}',       desc: 'Due date of the invoice' },
+  { tag: '{{days_until}}',     desc: 'Days until due (reminders only)' },
+  { tag: '{{days_overdue}}',   desc: 'Days past due (overdue notices only)' },
+]
+
+function openCreate() {
+  Object.assign(form, EMPTY_FORM())
+  editingId.value = null
+  formError.value = ''
+  showModal.value  = true
+}
+
+function openEdit(client) {
+  Object.assign(form, {
+    client_id_name: client.client_id_name,
+    client_name:    client.client_name,
+    client_email:   client.client_email,
+  })
+  editingId.value = client.id
+  formError.value = ''
+  showModal.value  = true
+}
+
+function confirmDelete(id) {
+  deleteId.value    = id
+  showConfirm.value = true
+}
+
+async function save() {
+  saving.value    = true
+  formError.value = ''
+  try {
+    if (editingId.value) {
+      await store.update(editingId.value, { ...form })
+    } else {
+      await store.create({ ...form })
+    }
+    showModal.value = false
+  } catch (e) {
+    formError.value = e.response?.data?.detail ?? 'Save failed.'
+  } finally {
+    saving.value = false
+  }
+}
+
+async function remove() {
+  showConfirm.value = false
+  try {
+    await store.remove(deleteId.value)
+  } catch (e) {
+    store.error = e.response?.data?.detail ?? 'Delete failed.'
+  }
+}
+
+onMounted(() => store.fetchAll())
+</script>
+
+<template>
+  <div class="space-y-5 animate-fade-in">
+
+    <!-- Header -->
+    <div class="flex flex-col sm:flex-row sm:items-center gap-4">
+      <div>
+        <h2 class="text-xl font-bold text-white">Clients</h2>
+        <p class="text-sm text-slate-400 mt-0.5">Manage customer profiles used for invoice targeting</p>
+      </div>
+      <button id="clients-add-btn" @click="openCreate" class="sm:ml-auto btn-primary">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
+        </svg>
+        Add Client
+      </button>
+    </div>
+
+    <!-- Template tags hint -->
+    <div class="card p-4 border-brand-500/20 bg-brand-500/5">
+      <p class="text-xs font-semibold text-brand-400 mb-2.5 flex items-center gap-1.5">
+        <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+        </svg>
+        Available Jinja2 Template Tags
+      </p>
+      <div class="flex flex-wrap gap-2">
+        <div v-for="t in TEMPLATE_TAGS" :key="t.tag"
+             class="group relative chip cursor-default">
+          <code>{{ t.tag }}</code>
+          <!-- Tooltip -->
+          <span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:block
+                       bg-slate-800 border border-slate-700 text-slate-300 text-[10px] rounded px-2 py-1
+                       whitespace-nowrap z-10 pointer-events-none shadow-lg">
+            {{ t.desc }}
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <!-- Error -->
+    <div v-if="store.error" class="card p-4 flex items-center gap-3 border-red-500/30">
+      <p class="text-sm text-red-400">{{ store.error }}</p>
+      <button @click="store.error = ''" class="ml-auto btn-icon text-red-400">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+        </svg>
+      </button>
+    </div>
+
+    <!-- Loading -->
+    <div v-if="store.loading" class="card p-12 flex flex-col items-center gap-3 text-slate-500">
+      <svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
+        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+      </svg>
+      <span class="text-sm">Loading clients…</span>
+    </div>
+
+    <!-- Table -->
+    <div v-else class="card overflow-hidden">
+      <div class="overflow-x-auto">
+        <table class="w-full">
+          <thead class="bg-slate-800/60">
+            <tr>
+              <th class="table-header-cell w-16">ID</th>
+              <th class="table-header-cell">Slug</th>
+              <th class="table-header-cell">Client Name</th>
+              <th class="table-header-cell">Email</th>
+              <th class="table-header-cell w-24 text-right">Actions</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="store.clients.length === 0">
+              <td colspan="5" class="px-4 py-12 text-center text-sm text-slate-500">
+                No clients yet. Click "Add Client" to get started.
+              </td>
+            </tr>
+            <tr v-for="client in store.clients" :key="client.id" class="table-row">
+              <td class="table-cell text-slate-500 font-mono text-xs">{{ client.id }}</td>
+              <td class="table-cell">
+                <code class="font-mono text-xs text-violet-300 bg-violet-500/10 px-2 py-0.5 rounded">{{ client.client_id_name }}</code>
+              </td>
+              <td class="table-cell font-medium text-slate-100">{{ client.client_name }}</td>
+              <td class="table-cell text-slate-300">{{ client.client_email }}</td>
+              <td class="table-cell text-right">
+                <div class="flex items-center justify-end gap-1">
+                  <button :id="`client-edit-${client.id}`" @click="openEdit(client)" class="btn-icon" title="Edit">
+                    <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+                    </svg>
+                  </button>
+                  <button :id="`client-delete-${client.id}`" @click="confirmDelete(client.id)" class="btn-icon hover:text-red-400 hover:bg-red-500/10" title="Delete">
+                    <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                    </svg>
+                  </button>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- Create / Edit Modal -->
+    <Modal
+      :show="showModal"
+      :title="editingId ? 'Edit Client' : 'Add New Client'"
+      size="sm"
+      @close="showModal = false"
+    >
+      <form id="client-form" @submit.prevent="save" class="space-y-4">
+        <div>
+          <label class="form-label" for="client-slug">ID Slug <span class="text-slate-600 font-normal normal-case">(unique short key)</span></label>
+          <input id="client-slug" v-model="form.client_id_name" type="text" required
+            placeholder="acme-corp" class="form-input" />
+        </div>
+        <div>
+          <label class="form-label" for="client-name">Client Name</label>
+          <input id="client-name" v-model="form.client_name" type="text" required
+            placeholder="Acme Corporation" class="form-input" />
+        </div>
+        <div>
+          <label class="form-label" for="client-email">Email</label>
+          <input id="client-email" v-model="form.client_email" type="email" required
+            placeholder="billing@acme.com" class="form-input" />
+        </div>
+        <div v-if="formError" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+          <p class="text-sm text-red-400">{{ formError }}</p>
+        </div>
+      </form>
+      <template #footer>
+        <button @click="showModal = false" class="btn-ghost">Cancel</button>
+        <button id="client-save-btn" form="client-form" type="submit" :disabled="saving" class="btn-primary">
+          <svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+          </svg>
+          {{ editingId ? 'Save Changes' : 'Add Client' }}
+        </button>
+      </template>
+    </Modal>
+
+    <!-- Delete Confirmation -->
+    <ConfirmDialog
+      :show="showConfirm"
+      title="Delete Client"
+      message="This will permanently remove the client. Any invoices referencing this client must be deleted first."
+      confirm-label="Delete"
+      :danger="true"
+      @confirm="remove"
+      @cancel="showConfirm = false"
+    />
+  </div>
+</template>

+ 162 - 0
frontend/src/components/Tabs/ConfigTab.vue

@@ -0,0 +1,162 @@
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import api from '@/api/index.js'
+
+const config  = ref([])
+const loading = ref(true)
+const error   = ref('')
+const search  = ref('')
+
+const filtered = computed(() => {
+  const q = search.value.toLowerCase().trim()
+  if (!q) return config.value
+  return config.value.filter(
+    e => e.key.toLowerCase().includes(q) || (!e.masked && e.value.toLowerCase().includes(q))
+  )
+})
+
+const maskedCount = computed(() => config.value.filter(e => e.masked).length)
+
+onMounted(async () => {
+  try {
+    const { data } = await api.get('/config')
+    config.value = data
+  } catch {
+    error.value = 'Failed to load configuration from the server.'
+  } finally {
+    loading.value = false
+  }
+})
+
+async function refresh() {
+  loading.value = true
+  error.value   = ''
+  try {
+    const { data } = await api.get('/config')
+    config.value = data
+  } catch {
+    error.value = 'Refresh failed.'
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="space-y-5 animate-fade-in">
+
+    <!-- Header row -->
+    <div class="flex flex-col sm:flex-row sm:items-center gap-4">
+      <div>
+        <h2 class="text-xl font-bold text-white">Configuration</h2>
+        <p class="text-sm text-slate-400 mt-0.5">Live container environment variables · Sensitive values are automatically masked</p>
+      </div>
+      <div class="sm:ml-auto flex items-center gap-3">
+        <button id="config-refresh-btn" @click="refresh" :disabled="loading" class="btn-ghost gap-2">
+          <svg :class="['w-4 h-4', loading && 'animate-spin']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+          </svg>
+          Refresh
+        </button>
+      </div>
+    </div>
+
+    <!-- Stats bar -->
+    <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
+      <div class="card px-4 py-3">
+        <p class="text-xs text-slate-400">Total Variables</p>
+        <p class="text-2xl font-bold text-white mt-0.5">{{ config.length }}</p>
+      </div>
+      <div class="card px-4 py-3">
+        <p class="text-xs text-slate-400">Masked Secrets</p>
+        <p class="text-2xl font-bold text-red-400 mt-0.5">{{ maskedCount }}</p>
+      </div>
+      <div class="card px-4 py-3 col-span-2 sm:col-span-1">
+        <p class="text-xs text-slate-400">Search Results</p>
+        <p class="text-2xl font-bold text-brand-400 mt-0.5">{{ filtered.length }}</p>
+      </div>
+    </div>
+
+    <!-- Search -->
+    <div class="relative">
+      <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+      </svg>
+      <input
+        id="config-search"
+        v-model="search"
+        type="search"
+        placeholder="Filter variables…"
+        class="form-input pl-9"
+      />
+    </div>
+
+    <!-- Loading -->
+    <div v-if="loading" class="card p-12 flex flex-col items-center gap-3 text-slate-500">
+      <svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
+        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+      </svg>
+      <span class="text-sm">Loading environment…</span>
+    </div>
+
+    <!-- Error -->
+    <div v-else-if="error" class="card p-6 flex items-center gap-3 border-red-500/30">
+      <svg class="w-5 h-5 text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
+      </svg>
+      <p class="text-sm text-red-400">{{ error }}</p>
+    </div>
+
+    <!-- Table -->
+    <div v-else class="card overflow-hidden">
+      <table class="w-full">
+        <thead class="bg-slate-800/60">
+          <tr>
+            <th class="table-header-cell w-1/3">Variable</th>
+            <th class="table-header-cell">Value</th>
+            <th class="table-header-cell w-28 text-center">Masked</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="filtered.length === 0">
+            <td colspan="3" class="px-4 py-10 text-center text-sm text-slate-500">No variables match your search.</td>
+          </tr>
+          <tr v-for="entry in filtered" :key="entry.key" class="table-row">
+            <!-- Key -->
+            <td class="table-cell">
+              <code class="font-mono text-xs text-sky-300 bg-sky-500/10 px-2 py-0.5 rounded">{{ entry.key }}</code>
+            </td>
+            <!-- Value -->
+            <td class="table-cell font-mono text-xs">
+              <span v-if="entry.masked" class="text-slate-600 tracking-[0.3em]">••••••••</span>
+              <span v-else class="text-slate-300 break-all">{{ entry.value }}</span>
+            </td>
+            <!-- Masked badge -->
+            <td class="table-cell text-center">
+              <span v-if="entry.masked" class="badge-danger">
+                <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
+                  <path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
+                </svg>
+                Masked
+              </span>
+              <span v-else class="badge-muted">Visible</span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- Legend -->
+    <div class="flex flex-wrap items-center gap-4 text-xs text-slate-500 px-1">
+      <div class="flex items-center gap-1.5">
+        <span class="badge-danger">Masked</span>
+        Contains PASSWORD / SECRET / TOKEN / KEY / DATABASE_URL
+      </div>
+      <div class="flex items-center gap-1.5">
+        <span class="badge-muted">Visible</span>
+        Safe to display
+      </div>
+    </div>
+  </div>
+</template>

+ 460 - 0
frontend/src/components/Tabs/InvoicesTab.vue

@@ -0,0 +1,460 @@
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useInvoicesStore } from '@/store/invoices.js'
+import Modal from '@/components/ui/Modal.vue'
+import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
+
+// ── Store ─────────────────────────────────────────────────────────────────
+const store = useInvoicesStore()
+
+// ── Modal / confirm state ─────────────────────────────────────────────────
+const showModal   = ref(false)
+const showConfirm = ref(false)
+const editingId   = ref(null)
+const deleteId    = ref(null)
+const saving      = ref(false)
+const formError   = ref('')
+
+// ── Notification-date tag input ───────────────────────────────────────────
+const newDayInput = ref('')
+
+const EMPTY_FORM = () => ({
+  invoice_number:         '',
+  client_id:              null,
+  pay_date:               '',
+  template_id:            null,
+  notification_dates:     [],
+  overdue_recurring_days: 0,
+  paid:                   false,
+})
+const form = reactive(EMPTY_FORM())
+
+// ── Derived display helpers ───────────────────────────────────────────────
+function isOverdue(invoice) {
+  if (invoice.paid || !invoice.pay_date) return false
+  return new Date(invoice.pay_date) < new Date(new Date().toDateString())
+}
+
+function daysLabel(invoice) {
+  const today = new Date(new Date().toDateString())
+  const due   = new Date(invoice.pay_date)
+  const diff  = Math.round((due - today) / 86400000)
+  if (invoice.paid) return null
+  if (diff === 0) return { label: 'Due today',      cls: 'badge-warning' }
+  if (diff >  0)  return { label: `In ${diff}d`,    cls: 'badge-info'    }
+  return              { label: `${-diff}d overdue`, cls: 'badge-danger'  }
+}
+
+// ── CRUD handlers ─────────────────────────────────────────────────────────
+function openCreate() {
+  Object.assign(form, EMPTY_FORM())
+  editingId.value   = null
+  formError.value   = ''
+  newDayInput.value = ''
+  showModal.value   = true
+}
+
+function openEdit(invoice) {
+  Object.assign(form, {
+    invoice_number:         invoice.invoice_number,
+    client_id:              invoice.client_id,
+    pay_date:               invoice.pay_date,
+    template_id:            invoice.template_id,
+    notification_dates:     [...(invoice.notification_dates ?? [])],
+    overdue_recurring_days: invoice.overdue_recurring_days,
+    paid:                   invoice.paid,
+  })
+  editingId.value   = invoice.id
+  formError.value   = ''
+  newDayInput.value = ''
+  showModal.value   = true
+}
+
+function confirmDelete(id) {
+  deleteId.value    = id
+  showConfirm.value = true
+}
+
+// ── Notification date chips ───────────────────────────────────────────────
+function addDay() {
+  const val = parseInt(newDayInput.value)
+  if (!isNaN(val) && val > 0 && !form.notification_dates.includes(val)) {
+    form.notification_dates.push(val)
+    form.notification_dates.sort((a, b) => a - b)
+  }
+  newDayInput.value = ''
+}
+
+function removeDay(day) {
+  form.notification_dates = form.notification_dates.filter(d => d !== day)
+}
+
+function onDayKeydown(e) {
+  if (e.key === 'Enter') { e.preventDefault(); addDay() }
+}
+
+// ── Save ──────────────────────────────────────────────────────────────────
+async function save() {
+  saving.value    = true
+  formError.value = ''
+  try {
+    const payload = { ...form }
+    if (editingId.value) {
+      await store.update(editingId.value, payload)
+    } else {
+      await store.create(payload)
+    }
+    showModal.value = false
+  } catch (e) {
+    formError.value = e.response?.data?.detail ?? 'Save failed.'
+  } finally {
+    saving.value = false
+  }
+}
+
+// ── Toggle paid ───────────────────────────────────────────────────────────
+async function togglePaid(invoice) {
+  try {
+    await store.togglePaid(invoice)
+  } catch (e) {
+    store.error = e.response?.data?.detail ?? 'Failed to update paid status.'
+  }
+}
+
+// ── Delete ────────────────────────────────────────────────────────────────
+async function remove() {
+  showConfirm.value = false
+  try {
+    await store.remove(deleteId.value)
+  } catch (e) {
+    store.error = e.response?.data?.detail ?? 'Delete failed.'
+  }
+}
+
+onMounted(() => store.fetchAll())
+</script>
+
+<template>
+  <div class="space-y-5 animate-fade-in">
+
+    <!-- Header -->
+    <div class="flex flex-col sm:flex-row sm:items-center gap-4">
+      <div>
+        <h2 class="text-xl font-bold text-white">Invoices</h2>
+        <p class="text-sm text-slate-400 mt-0.5">Open receivables with automated notification scheduling</p>
+      </div>
+      <button id="invoices-add-btn" @click="openCreate" class="sm:ml-auto btn-primary">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
+        </svg>
+        New Invoice
+      </button>
+    </div>
+
+    <!-- Stats -->
+    <div class="grid grid-cols-3 gap-3">
+      <div class="card px-4 py-3">
+        <p class="text-xs text-slate-400">Total</p>
+        <p class="text-2xl font-bold text-white mt-0.5">{{ store.invoices.length }}</p>
+      </div>
+      <div class="card px-4 py-3">
+        <p class="text-xs text-slate-400">Open</p>
+        <p class="text-2xl font-bold text-amber-400 mt-0.5">{{ store.openInvoices.length }}</p>
+      </div>
+      <div class="card px-4 py-3">
+        <p class="text-xs text-slate-400">Paid</p>
+        <p class="text-2xl font-bold text-emerald-400 mt-0.5">{{ store.paidInvoices.length }}</p>
+      </div>
+    </div>
+
+    <!-- Overdue alert banner -->
+    <div
+      v-if="store.overdueInvoices.length > 0"
+      class="card p-4 flex items-center gap-3 border-red-500/40 bg-red-500/5"
+    >
+      <svg class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+      </svg>
+      <p class="text-sm text-red-400 font-medium">
+        {{ store.overdueInvoices.length }} overdue invoice{{ store.overdueInvoices.length !== 1 ? 's' : '' }} — run the cron scan to dispatch overdue notices.
+      </p>
+    </div>
+
+    <!-- Error -->
+    <div v-if="store.error" class="card p-4 flex items-center gap-3 border-red-500/30">
+      <p class="text-sm text-red-400">{{ store.error }}</p>
+      <button @click="store.error = ''" class="ml-auto btn-icon text-red-400">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+        </svg>
+      </button>
+    </div>
+
+    <!-- Loading -->
+    <div v-if="store.loading" class="card p-12 flex flex-col items-center gap-3 text-slate-500">
+      <svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
+        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+      </svg>
+      <span class="text-sm">Loading invoices…</span>
+    </div>
+
+    <!-- Table -->
+    <div v-else class="card overflow-hidden">
+      <div class="overflow-x-auto">
+        <table class="w-full min-w-[900px]">
+          <thead class="bg-slate-800/60">
+            <tr>
+              <th class="table-header-cell w-16">ID</th>
+              <th class="table-header-cell">Invoice #</th>
+              <th class="table-header-cell">Client</th>
+              <th class="table-header-cell w-32">Due Date</th>
+              <th class="table-header-cell">Template</th>
+              <th class="table-header-cell w-28">Notify Days</th>
+              <th class="table-header-cell w-28 text-center">Status</th>
+              <th class="table-header-cell w-28 text-center">Paid</th>
+              <th class="table-header-cell w-24 text-right">Actions</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="store.invoices.length === 0">
+              <td colspan="9" class="px-4 py-12 text-center text-sm text-slate-500">
+                No invoices yet. Click "New Invoice" to create one.
+              </td>
+            </tr>
+            <tr
+              v-for="invoice in store.invoices"
+              :key="invoice.id"
+              :class="['table-row', invoice.paid ? 'opacity-50' : '']"
+            >
+              <td class="table-cell text-slate-500 font-mono text-xs">{{ invoice.id }}</td>
+              <td class="table-cell">
+                <code class="font-mono text-xs text-sky-300 bg-sky-500/10 px-2 py-0.5 rounded">{{ invoice.invoice_number }}</code>
+              </td>
+              <td class="table-cell font-medium text-slate-100">{{ invoice.client?.client_name ?? store.clientName(invoice.client_id) }}</td>
+              <td class="table-cell text-slate-300 text-xs font-mono whitespace-nowrap">{{ invoice.pay_date }}</td>
+              <td class="table-cell">
+                <span class="text-xs text-slate-400">{{ invoice.template?.name ?? store.templateName(invoice.template_id) }}</span>
+              </td>
+              <!-- Notification days chips -->
+              <td class="table-cell">
+                <div class="flex flex-wrap gap-1">
+                  <span
+                    v-for="d in (invoice.notification_dates ?? [])"
+                    :key="d"
+                    class="inline-flex items-center px-1.5 py-0.5 bg-brand-500/15 text-brand-400 rounded text-[10px] font-mono"
+                  >-{{ d }}d</span>
+                  <span v-if="!(invoice.notification_dates ?? []).length" class="text-slate-600 text-xs">—</span>
+                </div>
+              </td>
+              <!-- Status badge -->
+              <td class="table-cell text-center">
+                <template v-if="invoice.paid">
+                  <span class="badge-success">Paid</span>
+                </template>
+                <template v-else>
+                  <span v-if="daysLabel(invoice)" :class="daysLabel(invoice).cls">
+                    {{ daysLabel(invoice).label }}
+                  </span>
+                </template>
+              </td>
+              <!-- Paid toggle -->
+              <td class="table-cell text-center">
+                <button
+                  :id="`invoice-paid-toggle-${invoice.id}`"
+                  @click="togglePaid(invoice)"
+                  :class="[
+                    'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-slate-900',
+                    invoice.paid ? 'bg-emerald-600' : 'bg-slate-700',
+                  ]"
+                  :title="invoice.paid ? 'Mark as unpaid' : 'Mark as paid'"
+                >
+                  <span
+                    :class="[
+                      'inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200',
+                      invoice.paid ? 'translate-x-[18px]' : 'translate-x-[3px]',
+                    ]"
+                  />
+                </button>
+              </td>
+              <!-- Actions -->
+              <td class="table-cell text-right">
+                <div class="flex items-center justify-end gap-1">
+                  <button :id="`invoice-edit-${invoice.id}`" @click="openEdit(invoice)" class="btn-icon" title="Edit">
+                    <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+                    </svg>
+                  </button>
+                  <button :id="`invoice-delete-${invoice.id}`" @click="confirmDelete(invoice.id)" class="btn-icon hover:text-red-400 hover:bg-red-500/10" title="Delete">
+                    <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                    </svg>
+                  </button>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- Create / Edit Modal -->
+    <Modal
+      :show="showModal"
+      :title="editingId ? 'Edit Invoice' : 'New Invoice'"
+      size="lg"
+      @close="showModal = false"
+    >
+      <form id="invoice-form" @submit.prevent="save" class="space-y-4">
+
+        <!-- Row 1: Invoice # + Client -->
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="form-label" for="inv-number">Invoice Number</label>
+            <input id="inv-number" v-model="form.invoice_number" type="text" required
+              placeholder="INV-2024-001" class="form-input" />
+          </div>
+          <div>
+            <label class="form-label" for="inv-client">Client</label>
+            <select id="inv-client" v-model="form.client_id" required class="form-select">
+              <option :value="null" disabled>Select a client…</option>
+              <option v-for="c in store.clients" :key="c.id" :value="c.id">
+                {{ c.client_name }} ({{ c.client_id_name }})
+              </option>
+            </select>
+          </div>
+        </div>
+
+        <!-- Row 2: Pay date + Template -->
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="form-label" for="inv-paydate">Due Date</label>
+            <input id="inv-paydate" v-model="form.pay_date" type="date" required class="form-input" />
+          </div>
+          <div>
+            <label class="form-label" for="inv-template">Email Template</label>
+            <select id="inv-template" v-model="form.template_id" required class="form-select">
+              <option :value="null" disabled>Select a template…</option>
+              <option v-for="t in store.templates" :key="t.id" :value="t.id">
+                {{ t.name }} ({{ t.template_type }})
+              </option>
+            </select>
+          </div>
+        </div>
+
+        <!-- Notification dates tag input -->
+        <div>
+          <label class="form-label">
+            Notification Days Before Due
+            <span class="text-slate-600 normal-case font-normal ml-1">— days before the due date to send a reminder</span>
+          </label>
+          <!-- Chips display -->
+          <div class="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
+            <span v-if="form.notification_dates.length === 0" class="text-xs text-slate-600 italic">No days added yet</span>
+            <span
+              v-for="d in form.notification_dates"
+              :key="d"
+              class="chip flex items-center gap-1"
+            >
+              {{ d }} day{{ d !== 1 ? 's' : '' }}
+              <button type="button" @click="removeDay(d)"
+                class="ml-0.5 hover:text-red-400 transition-colors"
+                :aria-label="`Remove ${d} days`">
+                <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
+                </svg>
+              </button>
+            </span>
+          </div>
+          <!-- Input row -->
+          <div class="flex gap-2">
+            <input
+              id="inv-notify-day"
+              v-model="newDayInput"
+              type="number"
+              min="1"
+              max="365"
+              placeholder="e.g. 7"
+              class="form-input w-28"
+              @keydown="onDayKeydown"
+            />
+            <button type="button" @click="addDay" class="btn-ghost flex-shrink-0">
+              <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
+              </svg>
+              Add Day
+            </button>
+          </div>
+          <p class="text-[11px] text-slate-500 mt-1.5">Press Enter or click "Add Day". Common values: 30, 14, 7, 3, 1.</p>
+        </div>
+
+        <!-- Overdue interval + Paid toggle -->
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="form-label" for="inv-overdue-days">
+              Overdue Recurring Interval
+              <span class="text-slate-600 normal-case font-normal ml-1">(days, 0 = disabled)</span>
+            </label>
+            <input id="inv-overdue-days" v-model.number="form.overdue_recurring_days" type="number"
+              min="0" placeholder="7" class="form-input" />
+            <p class="text-[11px] text-slate-500 mt-1">Send overdue notices every N days past the due date.</p>
+          </div>
+
+          <div>
+            <label class="form-label">Paid Status</label>
+            <div class="flex items-center gap-3 mt-2">
+              <button
+                id="inv-paid-toggle"
+                type="button"
+                @click="form.paid = !form.paid"
+                :class="[
+                  'relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-slate-900',
+                  form.paid ? 'bg-emerald-600' : 'bg-slate-700',
+                ]"
+              >
+                <span
+                  :class="[
+                    'inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
+                    form.paid ? 'translate-x-[22px]' : 'translate-x-[3px]',
+                  ]"
+                />
+              </button>
+              <div>
+                <p class="text-sm font-medium text-slate-200">{{ form.paid ? 'Paid' : 'Unpaid' }}</p>
+                <p class="text-[11px] text-slate-500">
+                  {{ form.paid ? 'All queue actions are silenced for this invoice.' : 'Active — reminders and overdue notices will fire.' }}
+                </p>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div v-if="formError" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+          <p class="text-sm text-red-400">{{ formError }}</p>
+        </div>
+      </form>
+
+      <template #footer>
+        <button @click="showModal = false" class="btn-ghost">Cancel</button>
+        <button id="invoice-save-btn" form="invoice-form" type="submit" :disabled="saving" class="btn-primary">
+          <svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+          </svg>
+          {{ editingId ? 'Save Changes' : 'Create Invoice' }}
+        </button>
+      </template>
+    </Modal>
+
+    <!-- Delete Confirmation -->
+    <ConfirmDialog
+      :show="showConfirm"
+      title="Delete Invoice"
+      message="This will permanently remove the invoice and all its notification log entries."
+      confirm-label="Delete Invoice"
+      :danger="true"
+      @confirm="remove"
+      @cancel="showConfirm = false"
+    />
+  </div>
+</template>

+ 134 - 0
frontend/src/components/Tabs/NotificationsTab.vue

@@ -0,0 +1,134 @@
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useNotificationsStore } from '@/store/notifications.js'
+
+const store = useNotificationsStore()
+const search = ref('')
+const filterStatus = ref('all')
+
+const STATUS_COLORS = {
+  'Delivered from Server': 'badge-success',
+  'Sent to Mail Relay':    'badge-info',
+  'Failed':                'badge-danger',
+}
+
+const filtered = computed(() => {
+  let result = store.logs
+  if (filterStatus.value !== 'all') {
+    result = result.filter(l => l.status === filterStatus.value)
+  }
+  if (search.value.trim()) {
+    const q = search.value.toLowerCase()
+    result = result.filter(l =>
+      l.context?.invoice_number?.toLowerCase().includes(q) ||
+      l.context?.client_name?.toLowerCase().includes(q) ||
+      l.status.toLowerCase().includes(q)
+    )
+  }
+  return result
+})
+
+function formatDate(iso) {
+  if (!iso) return '—'
+  return new Date(iso).toLocaleString(undefined, {
+    year: 'numeric', month: 'short', day: 'numeric',
+    hour: '2-digit', minute: '2-digit',
+  })
+}
+
+onMounted(() => store.fetchAll())
+</script>
+
+<template>
+  <div class="space-y-5 animate-fade-in">
+    <div class="flex flex-col sm:flex-row sm:items-center gap-4">
+      <div>
+        <h2 class="text-xl font-bold text-white">Notification Audit Log</h2>
+        <p class="text-sm text-slate-400 mt-0.5">All email dispatch attempts — newest first</p>
+      </div>
+      <button id="notifications-refresh-btn" @click="store.fetchAll()" :disabled="store.loading" class="sm:ml-auto btn-ghost">
+        <svg :class="['w-4 h-4', store.loading && 'animate-spin']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+        </svg>
+        Refresh
+      </button>
+    </div>
+
+    <!-- Failed banner -->
+    <div v-if="store.hasFailed && !store.loading" class="card p-4 flex items-center gap-3 border-red-500/40 bg-red-500/5">
+      <svg class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+      </svg>
+      <p class="text-sm text-red-400 font-medium">
+        {{ store.failed }} failed delivery{{ store.failed !== 1 ? 's' : '' }} — the next cron run will attempt automatic recovery.
+      </p>
+    </div>
+
+    <!-- Stats cards -->
+    <div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Total Logs</p><p class="text-2xl font-bold text-white mt-0.5">{{ store.total }}</p></div>
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Delivered</p><p class="text-2xl font-bold text-emerald-400 mt-0.5">{{ store.delivered }}</p></div>
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Via Relay</p><p class="text-2xl font-bold text-sky-400 mt-0.5">{{ store.relay }}</p></div>
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Failed</p><p class="text-2xl font-bold text-red-400 mt-0.5">{{ store.failed }}</p></div>
+    </div>
+
+    <!-- Filters -->
+    <div class="flex flex-col sm:flex-row gap-3">
+      <div class="relative flex-1">
+        <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+        </svg>
+        <input id="notifications-search" v-model="search" type="search" placeholder="Search invoice # or client…" class="form-input pl-9" />
+      </div>
+      <select id="notifications-filter" v-model="filterStatus" class="form-select w-full sm:w-56">
+        <option value="all">All statuses</option>
+        <option value="Delivered from Server">Delivered from Server</option>
+        <option value="Sent to Mail Relay">Sent to Mail Relay</option>
+        <option value="Failed">Failed</option>
+      </select>
+    </div>
+
+    <div v-if="store.loading" class="card p-12 flex flex-col items-center gap-3 text-slate-500">
+      <svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
+      <span class="text-sm">Loading logs…</span>
+    </div>
+    <div v-else-if="store.error" class="card p-6 flex items-center gap-3 border-red-500/30">
+      <p class="text-sm text-red-400">{{ store.error }}</p>
+    </div>
+
+    <div v-else class="card overflow-hidden">
+      <div class="overflow-x-auto">
+        <table class="w-full min-w-[700px]">
+          <thead class="bg-slate-800/60">
+            <tr>
+              <th class="table-header-cell w-16">#</th>
+              <th class="table-header-cell">Invoice</th>
+              <th class="table-header-cell">Client</th>
+              <th class="table-header-cell">Type</th>
+              <th class="table-header-cell">Timestamp</th>
+              <th class="table-header-cell">Status</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="filtered.length === 0">
+              <td colspan="6" class="px-4 py-12 text-center text-sm text-slate-500">
+                {{ store.logs.length === 0 ? 'No notifications logged yet. Run the cron scan to generate entries.' : 'No logs match your current filter.' }}
+              </td>
+            </tr>
+            <tr v-for="log in filtered" :key="log.id" class="table-row">
+              <td class="table-cell text-slate-500 font-mono text-xs">{{ log.id }}</td>
+              <td class="table-cell"><code class="font-mono text-xs text-sky-300">{{ log.context?.invoice_number ?? '—' }}</code></td>
+              <td class="table-cell text-slate-300">{{ log.context?.client_name ?? '—' }}</td>
+              <td class="table-cell"><span class="badge-muted capitalize">{{ log.context?.trigger ?? '—' }}</span></td>
+              <td class="table-cell text-slate-400 text-xs whitespace-nowrap">{{ formatDate(log.sent_at) }}</td>
+              <td class="table-cell">
+                <span :class="STATUS_COLORS[log.status] ?? 'badge-muted'">{{ log.status }}</span>
+                <p v-if="log.error_message" class="text-xs text-red-400 mt-1 truncate max-w-xs" :title="log.error_message">{{ log.error_message }}</p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>

+ 146 - 0
frontend/src/components/Tabs/TemplatesTab.vue

@@ -0,0 +1,146 @@
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useTemplatesStore } from '@/store/templates.js'
+import Modal from '@/components/ui/Modal.vue'
+import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
+
+const store = useTemplatesStore()
+const showModal   = ref(false)
+const showConfirm = ref(false)
+const editingId   = ref(null)
+const deleteId    = ref(null)
+const saving      = ref(false)
+const TEMPLATE_TYPES = ['Notification', 'Overdue']
+const EMPTY_FORM = () => ({ name: '', template_type: 'Notification', subject: '', template_body: '' })
+const form = reactive(EMPTY_FORM())
+const formError = ref('')
+const TEMPLATE_TAGS = [
+  '{{client_name}}', '{{client_email}}', '{{invoice_number}}',
+  '{{pay_date}}', '{{days_until}}', '{{days_overdue}}',
+]
+
+function openCreate() { Object.assign(form, EMPTY_FORM()); editingId.value = null; formError.value = ''; showModal.value = true }
+function openEdit(t)  { Object.assign(form, { name: t.name, template_type: t.template_type, subject: t.subject, template_body: t.template_body }); editingId.value = t.id; formError.value = ''; showModal.value = true }
+function confirmDelete(id) { deleteId.value = id; showConfirm.value = true }
+function insertTag(tag) { form.template_body += (form.template_body ? ' ' : '') + tag }
+
+async function save() {
+  saving.value = true; formError.value = ''
+  try {
+    editingId.value ? await store.update(editingId.value, { ...form }) : await store.create({ ...form })
+    showModal.value = false
+  } catch (e) { formError.value = e.response?.data?.detail ?? 'Save failed.' }
+  finally { saving.value = false }
+}
+async function remove() {
+  showConfirm.value = false
+  try { await store.remove(deleteId.value) }
+  catch (e) { store.error = e.response?.data?.detail ?? 'Delete failed.' }
+}
+onMounted(() => store.fetchAll())
+</script>
+
+<template>
+  <div class="space-y-5 animate-fade-in">
+    <div class="flex flex-col sm:flex-row sm:items-center gap-4">
+      <div>
+        <h2 class="text-xl font-bold text-white">Email Templates</h2>
+        <p class="text-sm text-slate-400 mt-0.5">Jinja2 message definitions for Notification and Overdue alerts</p>
+      </div>
+      <button id="templates-add-btn" @click="openCreate" class="sm:ml-auto btn-primary">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
+        New Template
+      </button>
+    </div>
+
+    <div class="grid grid-cols-3 gap-3">
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Total</p><p class="text-2xl font-bold text-white mt-0.5">{{ store.count }}</p></div>
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Notification</p><p class="text-2xl font-bold text-sky-400 mt-0.5">{{ store.notificationTemplates.length }}</p></div>
+      <div class="card px-4 py-3"><p class="text-xs text-slate-400">Overdue</p><p class="text-2xl font-bold text-amber-400 mt-0.5">{{ store.overdueTemplates.length }}</p></div>
+    </div>
+
+    <div v-if="store.error" class="card p-4 flex items-center gap-3 border-red-500/30">
+      <p class="text-sm text-red-400">{{ store.error }}</p>
+      <button @click="store.error = ''" class="ml-auto btn-icon text-red-400"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
+    </div>
+
+    <div v-if="store.loading" class="card p-12 flex flex-col items-center gap-3 text-slate-500">
+      <svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
+      <span class="text-sm">Loading templates…</span>
+    </div>
+
+    <div v-else class="card overflow-hidden">
+      <div class="overflow-x-auto">
+        <table class="w-full">
+          <thead class="bg-slate-800/60">
+            <tr>
+              <th class="table-header-cell w-16">ID</th>
+              <th class="table-header-cell">Name</th>
+              <th class="table-header-cell w-36">Type</th>
+              <th class="table-header-cell">Subject</th>
+              <th class="table-header-cell w-24 text-right">Actions</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="store.templates.length === 0"><td colspan="5" class="px-4 py-12 text-center text-sm text-slate-500">No templates yet. Click "New Template" to create one.</td></tr>
+            <tr v-for="t in store.templates" :key="t.id" class="table-row">
+              <td class="table-cell text-slate-500 font-mono text-xs">{{ t.id }}</td>
+              <td class="table-cell font-medium text-slate-100">{{ t.name }}</td>
+              <td class="table-cell"><span :class="t.template_type === 'Notification' ? 'badge-info' : 'badge-warning'">{{ t.template_type }}</span></td>
+              <td class="table-cell text-slate-300 truncate max-w-xs">{{ t.subject }}</td>
+              <td class="table-cell text-right">
+                <div class="flex items-center justify-end gap-1">
+                  <button :id="`template-edit-${t.id}`" @click="openEdit(t)" class="btn-icon" title="Edit"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
+                  <button :id="`template-delete-${t.id}`" @click="confirmDelete(t.id)" class="btn-icon hover:text-red-400 hover:bg-red-500/10" title="Delete"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <Modal :show="showModal" :title="editingId ? 'Edit Template' : 'New Email Template'" size="lg" @close="showModal = false">
+      <form id="template-form" @submit.prevent="save" class="space-y-4">
+        <div class="grid grid-cols-2 gap-4">
+          <div class="col-span-2 sm:col-span-1">
+            <label class="form-label" for="tmpl-name">Template Name</label>
+            <input id="tmpl-name" v-model="form.name" type="text" required placeholder="7-Day Reminder" class="form-input" />
+          </div>
+          <div class="col-span-2 sm:col-span-1">
+            <label class="form-label" for="tmpl-type">Type</label>
+            <select id="tmpl-type" v-model="form.template_type" required class="form-select">
+              <option v-for="type in TEMPLATE_TYPES" :key="type" :value="type">{{ type }}</option>
+            </select>
+          </div>
+        </div>
+        <div>
+          <label class="form-label" for="tmpl-subject">Subject Line</label>
+          <input id="tmpl-subject" v-model="form.subject" type="text" required placeholder="Invoice {{invoice_number}} is due in {{days_until}} days" class="form-input" />
+        </div>
+        <div>
+          <label class="form-label">Quick-insert Tags</label>
+          <div class="flex flex-wrap gap-1.5">
+            <button v-for="tag in TEMPLATE_TAGS" :key="tag" type="button" @click="insertTag(tag)" class="chip hover:bg-brand-600/30 transition-colors cursor-pointer text-[10px]">{{ tag }}</button>
+          </div>
+        </div>
+        <div>
+          <label class="form-label" for="tmpl-body">Template Body</label>
+          <textarea id="tmpl-body" v-model="form.template_body" required rows="10"
+            placeholder="Dear {{client_name}},&#10;&#10;Invoice {{invoice_number}} is due on {{pay_date}}."
+            class="form-textarea font-mono text-xs leading-relaxed" />
+        </div>
+        <div v-if="formError" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg"><p class="text-sm text-red-400">{{ formError }}</p></div>
+      </form>
+      <template #footer>
+        <button @click="showModal = false" class="btn-ghost">Cancel</button>
+        <button id="template-save-btn" form="template-form" type="submit" :disabled="saving" class="btn-primary">
+          <svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
+          {{ editingId ? 'Save Changes' : 'Create Template' }}
+        </button>
+      </template>
+    </Modal>
+
+    <ConfirmDialog :show="showConfirm" title="Delete Template" message="This will permanently remove the email template. Invoices referencing it must be updated first." confirm-label="Delete" :danger="true" @confirm="remove" @cancel="showConfirm = false" />
+  </div>
+</template>

+ 74 - 0
frontend/src/components/ui/ConfirmDialog.vue

@@ -0,0 +1,74 @@
+<script setup>
+/**
+ * ConfirmDialog.vue — lightweight destructive-action confirmation dialog
+ *
+ * Props:
+ *   show          Boolean — visibility
+ *   title         String  — dialog heading
+ *   message       String  — body text
+ *   confirmLabel  String  — confirm button label (default 'Confirm')
+ *   danger        Boolean — use red destructive styling (default true)
+ *
+ * Emits:
+ *   confirm  — user clicked confirm
+ *   cancel   — user clicked cancel or backdrop
+ */
+defineProps({
+  show:         { type: Boolean, required: true },
+  title:        { type: String,  default: 'Confirm Action' },
+  message:      { type: String,  default: 'Are you sure you want to proceed? This action cannot be undone.' },
+  confirmLabel: { type: String,  default: 'Confirm' },
+  danger:       { type: Boolean, default: true },
+})
+
+const emit = defineEmits(['confirm', 'cancel'])
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="cf-fade">
+      <div v-if="show" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/75 backdrop-blur-sm" @click="emit('cancel')" />
+
+        <!-- Panel -->
+        <div class="relative w-full max-w-sm bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl p-6 animate-slide-up">
+          <div class="flex items-start gap-4">
+            <!-- Icon -->
+            <div :class="['w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
+                          danger ? 'bg-red-500/15' : 'bg-amber-500/15']">
+              <svg class="w-5 h-5" :class="danger ? 'text-red-400' : 'text-amber-400'"
+                   fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                  d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+              </svg>
+            </div>
+            <!-- Text -->
+            <div class="flex-1 min-w-0">
+              <h3 class="text-sm font-semibold text-white">{{ title }}</h3>
+              <p class="text-sm text-slate-400 mt-1.5 leading-relaxed">{{ message }}</p>
+            </div>
+          </div>
+
+          <!-- Actions -->
+          <div class="flex gap-3 mt-6 justify-end">
+            <button id="confirm-cancel" @click="emit('cancel')" class="btn-ghost">Cancel</button>
+            <button
+              id="confirm-proceed"
+              @click="emit('confirm')"
+              :class="danger ? 'btn-danger' : 'btn-primary'"
+            >
+              {{ confirmLabel }}
+            </button>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.cf-fade-enter-active { transition: opacity 0.15s ease; }
+.cf-fade-leave-active { transition: opacity 0.1s ease; }
+.cf-fade-enter-from, .cf-fade-leave-to { opacity: 0; }
+</style>

+ 94 - 0
frontend/src/components/ui/Modal.vue

@@ -0,0 +1,94 @@
+<script setup>
+/**
+ * Modal.vue — reusable dialog wrapper
+ * Props:
+ *   show   Boolean — controls visibility
+ *   title  String  — header title text
+ *   size   String  — 'sm' | 'md' | 'lg' | 'xl'  (default 'md')
+ *
+ * Slots:
+ *   default — body content
+ *   footer  — action buttons (optional; renders a padded footer row)
+ *
+ * Emits:
+ *   close — when backdrop or × button is clicked
+ */
+const props = defineProps({
+  show:  { type: Boolean, required: true },
+  title: { type: String, default: '' },
+  size:  { type: String, default: 'md' },
+})
+
+const emit = defineEmits(['close'])
+
+const sizeMap = {
+  sm: 'max-w-sm',
+  md: 'max-w-lg',
+  lg: 'max-w-2xl',
+  xl: 'max-w-4xl',
+}
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="modal-fade">
+      <div
+        v-if="show"
+        class="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto"
+        role="dialog"
+        aria-modal="true"
+        :aria-label="title"
+      >
+        <!-- Backdrop -->
+        <div
+          class="absolute inset-0 bg-black/70 backdrop-blur-sm"
+          @click="emit('close')"
+        />
+
+        <!-- Panel -->
+        <div
+          :class="[
+            'relative w-full bg-slate-900 border border-slate-700/80 rounded-2xl shadow-2xl shadow-black/60 my-auto',
+            sizeMap[size] ?? sizeMap.md,
+          ]"
+        >
+          <!-- Header -->
+          <div class="flex items-center justify-between px-6 py-4 border-b border-slate-800">
+            <h3 class="text-sm font-semibold text-white">{{ title }}</h3>
+            <button
+              @click="emit('close')"
+              class="btn-icon"
+              aria-label="Close modal"
+            >
+              <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+              </svg>
+            </button>
+          </div>
+
+          <!-- Body -->
+          <div class="px-6 py-5">
+            <slot />
+          </div>
+
+          <!-- Footer (optional) -->
+          <div
+            v-if="$slots.footer"
+            class="px-6 py-4 border-t border-slate-800 flex items-center justify-end gap-3"
+          >
+            <slot name="footer" />
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.modal-fade-enter-active { transition: all 0.2s ease-out; }
+.modal-fade-leave-active { transition: all 0.15s ease-in; }
+.modal-fade-enter-from,
+.modal-fade-leave-to    { opacity: 0; }
+.modal-fade-enter-from .relative { transform: scale(0.96) translateY(8px); }
+.modal-fade-leave-to   .relative { transform: scale(0.96) translateY(8px); }
+</style>

+ 10 - 0
frontend/src/main.js

@@ -0,0 +1,10 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import router from './router/index.js'
+import App from './App.vue'
+import './style.css'
+
+const app = createApp(App)
+app.use(createPinia())
+app.use(router)
+app.mount('#app')

+ 36 - 0
frontend/src/router/index.js

@@ -0,0 +1,36 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useAuthStore } from '@/store/auth.js'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    { path: '/', redirect: '/dashboard' },
+    {
+      path:      '/login',
+      name:      'Login',
+      component: () => import('@/views/LoginView.vue'),
+      meta:      { requiresGuest: true },
+    },
+    {
+      path:      '/dashboard',
+      name:      'Dashboard',
+      component: () => import('@/components/Dashboard.vue'),
+      meta:      { requiresAuth: true },
+    },
+    {
+      path:      '/mfa-setup',
+      name:      'MFASetup',
+      component: () => import('@/views/MFASetupView.vue'),
+      meta:      { requiresAuth: true },
+    },
+    { path: '/:pathMatch(.*)*', redirect: '/' },
+  ],
+})
+
+router.beforeEach((to) => {
+  const auth = useAuthStore()
+  if (to.meta.requiresAuth  && !auth.isAuthenticated) return '/login'
+  if (to.meta.requiresGuest &&  auth.isAuthenticated) return '/dashboard'
+})
+
+export default router

+ 92 - 0
frontend/src/store/auth.js

@@ -0,0 +1,92 @@
+/**
+ * store/auth.js
+ * Pinia authentication store.
+ *
+ * · Persists JWT in localStorage under 'pmdi_token'
+ * · Tracks MFA-pending state for the two-step login flow
+ * · Provides login(), verifyMFA(), fetchUser(), logout()
+ */
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import axios from 'axios'
+
+export const useAuthStore = defineStore('auth', () => {
+  // ── State ──────────────────────────────────────────────────────────────────
+  const token          = ref(localStorage.getItem('pmdi_token') || null)
+  const user           = ref(null)
+  const mfaPending     = ref(false)
+  const pendingUsername = ref('')
+
+  // ── Getters ────────────────────────────────────────────────────────────────
+  const isAuthenticated = computed(() => !!token.value && !mfaPending.value)
+
+  // ── Private helpers ────────────────────────────────────────────────────────
+  function _persist(t) {
+    token.value = t
+    if (t) localStorage.setItem('pmdi_token', t)
+    else    localStorage.removeItem('pmdi_token')
+  }
+
+  // ── Actions ────────────────────────────────────────────────────────────────
+
+  /**
+   * First-factor login. Returns { mfa_required: bool }.
+   * On MFA-required, stores a partial token; call verifyMFA() next.
+   */
+  async function login(username, password) {
+    const { data } = await axios.post('/api/auth/login', { username, password })
+    if (data.mfa_required) {
+      _persist(data.access_token)    // partial token — not yet full auth
+      mfaPending.value     = true
+      pendingUsername.value = username
+      return { mfa_required: true }
+    }
+    _persist(data.access_token)
+    mfaPending.value     = false
+    pendingUsername.value = ''
+    await fetchUser()
+    return { mfa_required: false }
+  }
+
+  /** Second-factor verification — exchanges partial token for a full JWT. */
+  async function verifyMFA(totpCode) {
+    const { data } = await axios.post('/api/auth/mfa/verify', {
+      username:  pendingUsername.value,
+      totp_code: totpCode,
+    })
+    _persist(data.access_token)
+    mfaPending.value     = false
+    pendingUsername.value = ''
+    await fetchUser()
+  }
+
+  /** Load the current user profile from /api/auth/me. */
+  async function fetchUser() {
+    if (!token.value) return
+    try {
+      const { data } = await axios.get('/api/auth/me', {
+        headers: { Authorization: `Bearer ${token.value}` },
+      })
+      user.value = data
+    } catch {
+      logout()
+    }
+  }
+
+  /** Clear all auth state and remove the persisted token. */
+  function logout() {
+    _persist(null)
+    user.value           = null
+    mfaPending.value     = false
+    pendingUsername.value = ''
+  }
+
+  return {
+    // state (exposed as readonly refs)
+    token, user, mfaPending, pendingUsername,
+    // getters
+    isAuthenticated,
+    // actions
+    login, verifyMFA, fetchUser, logout,
+  }
+})

+ 64 - 0
frontend/src/store/clients.js

@@ -0,0 +1,64 @@
+/**
+ * store/clients.js
+ * Pinia store — Clients domain
+ *
+ * Single source of truth for the clients list.  The InvoicesTab and
+ * ClientsTab both read from this store so they share one fetch.
+ */
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import api from '@/api/index.js'
+
+export const useClientsStore = defineStore('clients', () => {
+  // ── State ────────────────────────────────────────────────────────────────
+  const clients = ref([])
+  const loading = ref(false)
+  const error   = ref('')
+
+  // ── Getters ──────────────────────────────────────────────────────────────
+  const count = computed(() => clients.value.length)
+
+  /** Look up a client name by ID for display in tables. */
+  function nameById(id) {
+    return clients.value.find(c => c.id === id)?.client_name ?? `#${id}`
+  }
+
+  // ── Actions ──────────────────────────────────────────────────────────────
+
+  async function fetchAll() {
+    loading.value = true
+    error.value   = ''
+    try {
+      const { data } = await api.get('/clients')
+      clients.value = data
+    } catch (e) {
+      error.value = e.response?.data?.detail ?? 'Failed to load clients.'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function create(payload) {
+    const { data } = await api.post('/clients', payload)
+    clients.value.push(data)
+    return data
+  }
+
+  async function update(id, payload) {
+    const { data } = await api.put(`/clients/${id}`, payload)
+    const idx = clients.value.findIndex(c => c.id === id)
+    if (idx !== -1) clients.value[idx] = data
+    return data
+  }
+
+  async function remove(id) {
+    await api.delete(`/clients/${id}`)
+    clients.value = clients.value.filter(c => c.id !== id)
+  }
+
+  return {
+    clients, loading, error, count,
+    nameById,
+    fetchAll, create, update, remove,
+  }
+})

+ 117 - 0
frontend/src/store/invoices.js

@@ -0,0 +1,117 @@
+/**
+ * store/invoices.js
+ * Pinia store — Invoices domain
+ *
+ * Centralises all invoice data fetching, mutation, and derived state so that
+ * any component (present or future) reads from a single source of truth.
+ *
+ * Actions exposed:
+ *   fetchAll()               — load invoices + dependent clients & templates
+ *   create(payload)          — POST /api/invoices
+ *   update(id, payload)      — PUT  /api/invoices/:id
+ *   remove(id)               — DELETE /api/invoices/:id
+ *   togglePaid(invoice)      — convenience wrapper for the paid toggle
+ */
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import api from '@/api/index.js'
+
+export const useInvoicesStore = defineStore('invoices', () => {
+  // ── State ────────────────────────────────────────────────────────────────
+  const invoices  = ref([])
+  const clients   = ref([])
+  const templates = ref([])
+  const loading   = ref(false)
+  const error     = ref('')
+
+  // ── Getters ──────────────────────────────────────────────────────────────
+  const openInvoices    = computed(() => invoices.value.filter(i => !i.paid))
+  const paidInvoices    = computed(() => invoices.value.filter(i =>  i.paid))
+  const overdueInvoices = computed(() => {
+    const today = new Date(new Date().toDateString())
+    return invoices.value.filter(i => !i.paid && new Date(i.pay_date) < today)
+  })
+
+  // ── Helpers ──────────────────────────────────────────────────────────────
+  function clientName(id) {
+    return clients.value.find(c => c.id === id)?.client_name ?? `#${id}`
+  }
+
+  function templateName(id) {
+    return templates.value.find(t => t.id === id)?.name ?? `#${id}`
+  }
+
+  // ── Actions ──────────────────────────────────────────────────────────────
+
+  /**
+   * Load all invoices plus the client and template lists needed for display
+   * and form dropdowns.  Does a parallel triple-fetch so latency equals the
+   * slowest single request.
+   */
+  async function fetchAll() {
+    loading.value = true
+    error.value   = ''
+    try {
+      const [inv, cli, tpl] = await Promise.all([
+        api.get('/invoices'),
+        api.get('/clients'),
+        api.get('/templates'),
+      ])
+      invoices.value  = inv.data
+      clients.value   = cli.data
+      templates.value = tpl.data
+    } catch (e) {
+      error.value = e.response?.data?.detail ?? 'Failed to load invoices.'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  /** POST /api/invoices — returns the created invoice object. */
+  async function create(payload) {
+    const { data } = await api.post('/invoices', payload)
+    invoices.value.push(data)
+    return data
+  }
+
+  /** PUT /api/invoices/:id — replaces matching entry in state. */
+  async function update(id, payload) {
+    const { data } = await api.put(`/invoices/${id}`, payload)
+    const idx = invoices.value.findIndex(i => i.id === id)
+    if (idx !== -1) invoices.value[idx] = data
+    return data
+  }
+
+  /** DELETE /api/invoices/:id — removes from local state optimistically. */
+  async function remove(id) {
+    await api.delete(`/invoices/${id}`)
+    invoices.value = invoices.value.filter(i => i.id !== id)
+  }
+
+  /**
+   * Convenience action — flips the paid flag without needing the caller to
+   * reconstruct the full payload.
+   */
+  async function togglePaid(invoice) {
+    return update(invoice.id, {
+      invoice_number:         invoice.invoice_number,
+      client_id:              invoice.client_id,
+      pay_date:               invoice.pay_date,
+      template_id:            invoice.template_id,
+      notification_dates:     invoice.notification_dates ?? [],
+      overdue_recurring_days: invoice.overdue_recurring_days,
+      paid:                   !invoice.paid,
+    })
+  }
+
+  return {
+    // state
+    invoices, clients, templates, loading, error,
+    // getters
+    openInvoices, paidInvoices, overdueInvoices,
+    // helpers
+    clientName, templateName,
+    // actions
+    fetchAll, create, update, remove, togglePaid,
+  }
+})

+ 46 - 0
frontend/src/store/notifications.js

@@ -0,0 +1,46 @@
+/**
+ * store/notifications.js
+ * Pinia store — Notification Logs domain (read-only)
+ *
+ * Notification logs are append-only from the frontend's perspective.
+ * The cron endpoint writes them; the UI only reads and filters.
+ */
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import api from '@/api/index.js'
+
+export const useNotificationsStore = defineStore('notifications', () => {
+  // ── State ────────────────────────────────────────────────────────────────
+  const logs    = ref([])
+  const loading = ref(false)
+  const error   = ref('')
+
+  // ── Getters ──────────────────────────────────────────────────────────────
+  const total     = computed(() => logs.value.length)
+  const delivered = computed(() => logs.value.filter(l => l.status === 'Delivered from Server').length)
+  const relay     = computed(() => logs.value.filter(l => l.status === 'Sent to Mail Relay').length)
+  const failed    = computed(() => logs.value.filter(l => l.status === 'Failed').length)
+
+  const hasFailed = computed(() => failed.value > 0)
+
+  // ── Actions ──────────────────────────────────────────────────────────────
+
+  async function fetchAll() {
+    loading.value = true
+    error.value   = ''
+    try {
+      const { data } = await api.get('/notifications')
+      logs.value = data
+    } catch (e) {
+      error.value = e.response?.data?.detail ?? 'Failed to load notification logs.'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  return {
+    logs, loading, error,
+    total, delivered, relay, failed, hasFailed,
+    fetchAll,
+  }
+})

+ 63 - 0
frontend/src/store/templates.js

@@ -0,0 +1,63 @@
+/**
+ * store/templates.js
+ * Pinia store — Email Templates domain
+ */
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import api from '@/api/index.js'
+
+export const useTemplatesStore = defineStore('templates', () => {
+  // ── State ────────────────────────────────────────────────────────────────
+  const templates = ref([])
+  const loading   = ref(false)
+  const error     = ref('')
+
+  // ── Getters ──────────────────────────────────────────────────────────────
+  const count               = computed(() => templates.value.length)
+  const notificationTemplates = computed(() => templates.value.filter(t => t.template_type === 'Notification'))
+  const overdueTemplates      = computed(() => templates.value.filter(t => t.template_type === 'Overdue'))
+
+  function nameById(id) {
+    return templates.value.find(t => t.id === id)?.name ?? `#${id}`
+  }
+
+  // ── Actions ──────────────────────────────────────────────────────────────
+
+  async function fetchAll() {
+    loading.value = true
+    error.value   = ''
+    try {
+      const { data } = await api.get('/templates')
+      templates.value = data
+    } catch (e) {
+      error.value = e.response?.data?.detail ?? 'Failed to load email templates.'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function create(payload) {
+    const { data } = await api.post('/templates', payload)
+    templates.value.push(data)
+    return data
+  }
+
+  async function update(id, payload) {
+    const { data } = await api.put(`/templates/${id}`, payload)
+    const idx = templates.value.findIndex(t => t.id === id)
+    if (idx !== -1) templates.value[idx] = data
+    return data
+  }
+
+  async function remove(id) {
+    await api.delete(`/templates/${id}`)
+    templates.value = templates.value.filter(t => t.id !== id)
+  }
+
+  return {
+    templates, loading, error, count,
+    notificationTemplates, overdueTemplates,
+    nameById,
+    fetchAll, create, update, remove,
+  }
+})

+ 104 - 0
frontend/src/style.css

@@ -0,0 +1,104 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* ── Custom scrollbar ───────────────────────────────────────────────────────── */
+@layer base {
+  ::-webkit-scrollbar       { @apply w-1.5 h-1.5; }
+  ::-webkit-scrollbar-track { @apply bg-slate-900; }
+  ::-webkit-scrollbar-thumb { @apply bg-slate-600 rounded-full; }
+  ::-webkit-scrollbar-thumb:hover { @apply bg-slate-500; }
+
+  * { @apply box-border; }
+}
+
+/* ── Design system components ───────────────────────────────────────────────── */
+@layer components {
+
+  /* Buttons */
+  .btn-primary {
+    @apply inline-flex items-center gap-2 px-4 py-2
+           bg-brand-600 hover:bg-brand-500 active:bg-brand-700
+           text-white text-sm font-medium rounded-lg
+           transition-all duration-200 cursor-pointer
+           focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2 focus:ring-offset-slate-900
+           disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none;
+  }
+
+  .btn-danger {
+    @apply inline-flex items-center gap-2 px-4 py-2
+           bg-red-600 hover:bg-red-500 active:bg-red-700
+           text-white text-sm font-medium rounded-lg
+           transition-all duration-200 cursor-pointer
+           focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-slate-900;
+  }
+
+  .btn-ghost {
+    @apply inline-flex items-center gap-2 px-4 py-2
+           bg-transparent hover:bg-slate-700 active:bg-slate-600
+           text-slate-300 hover:text-white text-sm font-medium rounded-lg
+           transition-all duration-200 cursor-pointer
+           focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 focus:ring-offset-slate-900;
+  }
+
+  .btn-icon {
+    @apply p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700
+           transition-all duration-200 cursor-pointer;
+  }
+
+  /* Cards */
+  .card {
+    @apply bg-slate-900 border border-slate-800 rounded-xl;
+  }
+
+  /* Form controls */
+  .form-input {
+    @apply w-full bg-slate-800 border border-slate-700 text-slate-100 rounded-lg px-3 py-2
+           text-sm placeholder-slate-500
+           focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent
+           transition-all duration-150;
+  }
+
+  .form-select {
+    @apply form-input appearance-none cursor-pointer;
+  }
+
+  .form-textarea {
+    @apply form-input resize-none;
+  }
+
+  .form-label {
+    @apply block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5;
+  }
+
+  /* Table */
+  .table-header-cell {
+    @apply px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider;
+  }
+
+  .table-row {
+    @apply border-t border-slate-800 hover:bg-slate-800/50 transition-colors duration-100;
+  }
+
+  .table-cell {
+    @apply px-4 py-3 text-sm text-slate-200;
+  }
+
+  /* Badges */
+  .badge {
+    @apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium;
+  }
+
+  .badge-success { @apply badge bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/30; }
+  .badge-info    { @apply badge bg-sky-500/15     text-sky-400     ring-1 ring-sky-500/30; }
+  .badge-danger  { @apply badge bg-red-500/15     text-red-400     ring-1 ring-red-500/30; }
+  .badge-warning { @apply badge bg-amber-500/15   text-amber-400   ring-1 ring-amber-500/30; }
+  .badge-muted   { @apply badge bg-slate-500/15   text-slate-400   ring-1 ring-slate-500/30; }
+  .badge-violet  { @apply badge bg-violet-500/15  text-violet-400  ring-1 ring-violet-500/30; }
+
+  /* Chip (tag input) */
+  .chip {
+    @apply inline-flex items-center gap-1 px-2.5 py-1 bg-brand-600/20 text-brand-300
+           border border-brand-500/30 rounded-full text-xs font-medium;
+  }
+}

+ 179 - 0
frontend/src/views/LoginView.vue

@@ -0,0 +1,179 @@
+<script setup>
+import { ref, reactive } from 'vue'
+import { useRouter } from 'vue-router'
+import { useAuthStore } from '@/store/auth.js'
+
+const auth   = useAuthStore()
+const router = useRouter()
+
+const form        = reactive({ username: '', password: '' })
+const mfaCode     = ref('')
+const loading     = ref(false)
+const error       = ref('')
+const showMFA     = ref(false)
+
+async function handleLogin() {
+  error.value   = ''
+  loading.value = true
+  try {
+    const result = await auth.login(form.username, form.password)
+    if (result.mfa_required) {
+      showMFA.value = true
+    } else {
+      router.push('/dashboard')
+    }
+  } catch (e) {
+    error.value = e.response?.data?.detail ?? 'Login failed. Check your credentials.'
+  } finally {
+    loading.value = false
+  }
+}
+
+async function handleMFA() {
+  error.value   = ''
+  loading.value = true
+  try {
+    await auth.verifyMFA(mfaCode.value)
+    router.push('/dashboard')
+  } catch (e) {
+    error.value = e.response?.data?.detail ?? 'Invalid authenticator code.'
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="min-h-screen bg-slate-950 flex items-center justify-center p-4 relative overflow-hidden">
+
+    <!-- Ambient background orbs -->
+    <div class="absolute -top-52 -right-52 w-96 h-96 bg-brand-600/10 rounded-full blur-3xl pointer-events-none" />
+    <div class="absolute -bottom-52 -left-52 w-96 h-96 bg-violet-600/10 rounded-full blur-3xl pointer-events-none" />
+    <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-brand-950/20 rounded-full blur-3xl pointer-events-none" />
+
+    <div class="relative w-full max-w-sm animate-slide-up">
+
+      <!-- Brand header -->
+      <div class="text-center mb-8">
+        <div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-brand-500 to-violet-600 rounded-2xl mb-5 shadow-2xl shadow-brand-500/30">
+          <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+              d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+          </svg>
+        </div>
+        <h1 class="text-3xl font-extrabold text-white tracking-tight">PMDI</h1>
+        <p class="text-slate-400 mt-1.5 text-sm">Pay My Damn Invoice</p>
+      </div>
+
+      <!-- Card -->
+      <div class="card p-8 shadow-2xl shadow-black/60 backdrop-blur-xl">
+
+        <!-- ── Step 1: Username + Password ── -->
+        <div v-if="!showMFA">
+          <h2 class="text-lg font-semibold text-white mb-6">Sign in to your workspace</h2>
+          <form @submit.prevent="handleLogin" class="space-y-4" id="login-form">
+
+            <div>
+              <label class="form-label" for="login-username">Username</label>
+              <input
+                id="login-username"
+                v-model="form.username"
+                type="text"
+                autocomplete="username"
+                placeholder="admin"
+                required
+                class="form-input"
+              />
+            </div>
+
+            <div>
+              <label class="form-label" for="login-password">Password</label>
+              <input
+                id="login-password"
+                v-model="form.password"
+                type="password"
+                autocomplete="current-password"
+                placeholder="••••••••"
+                required
+                class="form-input"
+              />
+            </div>
+
+            <!-- Error -->
+            <Transition name="fade">
+              <div
+                v-if="error"
+                class="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"
+              >
+                <svg class="w-4 h-4 text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+                  <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
+                </svg>
+                <p class="text-sm text-red-400">{{ error }}</p>
+              </div>
+            </Transition>
+
+            <button id="login-submit" type="submit" :disabled="loading" class="btn-primary w-full justify-center mt-2 py-2.5">
+              <svg v-if="loading" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+              </svg>
+              {{ loading ? 'Signing in…' : 'Sign In' }}
+            </button>
+          </form>
+        </div>
+
+        <!-- ── Step 2: TOTP MFA ── -->
+        <div v-else class="animate-fade-in">
+          <div class="flex items-center gap-3 mb-6">
+            <div class="w-10 h-10 bg-brand-500/15 rounded-xl flex items-center justify-center flex-shrink-0">
+              <svg class="w-5 h-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+              </svg>
+            </div>
+            <div>
+              <h2 class="text-base font-semibold text-white">Two-Factor Verification</h2>
+              <p class="text-xs text-slate-400 mt-0.5">Enter the 6-digit code from your authenticator app</p>
+            </div>
+          </div>
+
+          <form @submit.prevent="handleMFA" class="space-y-4" id="mfa-form">
+            <div>
+              <label class="form-label" for="totp-code">Authenticator Code</label>
+              <input
+                id="totp-code"
+                v-model="mfaCode"
+                type="text"
+                inputmode="numeric"
+                maxlength="6"
+                autocomplete="one-time-code"
+                placeholder="000 000"
+                required
+                class="form-input text-center text-2xl tracking-[0.4em] font-mono"
+              />
+            </div>
+
+            <Transition name="fade">
+              <div v-if="error" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+                <p class="text-sm text-red-400">{{ error }}</p>
+              </div>
+            </Transition>
+
+            <button id="mfa-submit" type="submit" :disabled="loading" class="btn-primary w-full justify-center py-2.5">
+              {{ loading ? 'Verifying…' : 'Verify & Sign In' }}
+            </button>
+            <button type="button" @click="showMFA = false; error = ''" class="btn-ghost w-full justify-center">
+              ← Back to login
+            </button>
+          </form>
+        </div>
+      </div>
+
+      <p class="text-center text-slate-700 text-xs mt-6">PMDI v1.0.0 · Internal Use Only</p>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
+.fade-enter-from, .fade-leave-to { opacity: 0; }
+</style>

+ 300 - 0
frontend/src/views/MFASetupView.vue

@@ -0,0 +1,300 @@
+<script setup>
+/**
+ * MFASetupView.vue
+ * ────────────────
+ * Lets an authenticated admin configure TOTP-based MFA on their account.
+ *
+ * Flow:
+ *   1. GET  /api/auth/me          — check current MFA state
+ *   2. POST /api/auth/mfa/setup   — generate secret + QR URI (stored tentatively)
+ *   3. Display QR URI as text + otpauth:// link (user scans in authenticator app)
+ *   4. POST /api/auth/mfa/enable  — confirm with TOTP code → MFA active
+ *   5. POST /api/auth/mfa/disable — disable with TOTP code confirmation
+ *
+ * This view is accessible at /mfa-setup (requires auth).
+ * A button in the Dashboard sidebar can link here.
+ */
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useAuthStore } from '@/store/auth.js'
+import api from '@/api/index.js'
+
+const auth   = useAuthStore()
+const router = useRouter()
+
+// ── State ─────────────────────────────────────────────────────────────────
+const step        = ref('idle')   // idle | setup | confirm | disabling | done
+const secret      = ref('')
+const qrUri       = ref('')
+const totpCode    = ref('')
+const loading     = ref(false)
+const error       = ref('')
+const successMsg  = ref('')
+
+const mfaEnabled = computed(() => auth.user?.mfa_enabled ?? false)
+
+// ── Actions ───────────────────────────────────────────────────────────────
+
+/** Step 1 — call /mfa/setup to generate a new TOTP secret */
+async function startSetup() {
+  error.value   = ''
+  loading.value = true
+  try {
+    const { data } = await api.post('/auth/mfa/setup')
+    secret.value = data.secret
+    qrUri.value  = data.qr_uri
+    step.value   = 'confirm'
+  } catch (e) {
+    error.value = e.response?.data?.detail ?? 'Could not start MFA setup.'
+  } finally {
+    loading.value = false
+  }
+}
+
+/** Step 2 — confirm with the first TOTP code */
+async function enableMFA() {
+  if (!totpCode.value || totpCode.value.length !== 6) {
+    error.value = 'Please enter the 6-digit code from your authenticator.'
+    return
+  }
+  error.value   = ''
+  loading.value = true
+  try {
+    await api.post('/auth/mfa/enable', {
+      username:  auth.user.username,
+      totp_code: totpCode.value,
+    })
+    await auth.fetchUser()
+    successMsg.value = 'MFA has been enabled successfully. Your account is now protected with two-factor authentication.'
+    step.value   = 'done'
+    totpCode.value = ''
+  } catch (e) {
+    error.value = e.response?.data?.detail ?? 'Invalid code. Please try again.'
+  } finally {
+    loading.value = false
+  }
+}
+
+/** Disable MFA — requires a valid TOTP code for confirmation */
+async function disableMFA() {
+  if (!totpCode.value || totpCode.value.length !== 6) {
+    error.value = 'Please enter the 6-digit code from your authenticator.'
+    return
+  }
+  error.value   = ''
+  loading.value = true
+  try {
+    await api.post('/auth/mfa/disable', {
+      username:  auth.user.username,
+      totp_code: totpCode.value,
+    })
+    await auth.fetchUser()
+    successMsg.value = 'MFA has been disabled. Your account now uses password-only authentication.'
+    step.value   = 'done'
+    totpCode.value = ''
+  } catch (e) {
+    error.value = e.response?.data?.detail ?? 'Invalid code. Please try again.'
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  await auth.fetchUser()
+  step.value = mfaEnabled.value ? 'disabling' : 'idle'
+})
+</script>
+
+<template>
+  <div class="min-h-screen bg-slate-950 flex items-center justify-center p-4 relative overflow-hidden">
+    <!-- Ambient orbs -->
+    <div class="absolute -top-52 -right-52 w-96 h-96 bg-brand-600/10 rounded-full blur-3xl pointer-events-none" />
+    <div class="absolute -bottom-52 -left-52 w-96 h-96 bg-violet-600/10 rounded-full blur-3xl pointer-events-none" />
+
+    <div class="relative w-full max-w-md animate-slide-up">
+      <!-- Back button -->
+      <button @click="router.push('/dashboard')" class="flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
+        </svg>
+        Back to Dashboard
+      </button>
+
+      <!-- Card -->
+      <div class="card p-8 shadow-2xl shadow-black/60">
+        <!-- Header -->
+        <div class="flex items-center gap-4 mb-6">
+          <div class="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
+               :class="mfaEnabled ? 'bg-emerald-500/15' : 'bg-brand-500/15'">
+            <svg class="w-6 h-6" :class="mfaEnabled ? 'text-emerald-400' : 'text-brand-400'"
+                 fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+                d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+            </svg>
+          </div>
+          <div>
+            <h1 class="text-lg font-bold text-white">Two-Factor Authentication</h1>
+            <p class="text-xs text-slate-400 mt-0.5">
+              Status:
+              <span v-if="mfaEnabled" class="text-emerald-400 font-medium">Enabled ✓</span>
+              <span v-else class="text-slate-500 font-medium">Disabled</span>
+            </p>
+          </div>
+        </div>
+
+        <!-- ── Success state ───────────────────────────────────────────── -->
+        <div v-if="step === 'done'" class="space-y-4">
+          <div class="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-xl">
+            <p class="text-sm text-emerald-400">{{ successMsg }}</p>
+          </div>
+          <button @click="router.push('/dashboard')" class="btn-primary w-full justify-center">
+            Return to Dashboard
+          </button>
+        </div>
+
+        <!-- ── MFA Enabled: show disable flow ─────────────────────────── -->
+        <div v-else-if="step === 'disabling'" class="space-y-5">
+          <div class="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-xl">
+            <p class="text-xs font-semibold text-emerald-400 mb-1">MFA is Active</p>
+            <p class="text-sm text-slate-400">Your account is protected with TOTP two-factor authentication. Enter your authenticator code below to disable it.</p>
+          </div>
+
+          <div>
+            <label class="form-label" for="disable-totp">Current Authenticator Code</label>
+            <input
+              id="disable-totp"
+              v-model="totpCode"
+              type="text"
+              inputmode="numeric"
+              maxlength="6"
+              autocomplete="one-time-code"
+              placeholder="000 000"
+              class="form-input text-center text-xl tracking-[0.4em] font-mono"
+            />
+          </div>
+
+          <div v-if="error" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+            <p class="text-sm text-red-400">{{ error }}</p>
+          </div>
+
+          <button
+            id="mfa-disable-btn"
+            @click="disableMFA"
+            :disabled="loading"
+            class="btn-danger w-full justify-center"
+          >
+            <svg v-if="loading" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+            </svg>
+            {{ loading ? 'Disabling…' : 'Disable MFA' }}
+          </button>
+        </div>
+
+        <!-- ── MFA Disabled: show enable flow ─────────────────────────── -->
+        <div v-else-if="step === 'idle'" class="space-y-5">
+          <div class="p-4 bg-slate-800/60 border border-slate-700 rounded-xl space-y-2">
+            <p class="text-xs font-semibold text-slate-300">Setup Instructions</p>
+            <ol class="text-sm text-slate-400 space-y-1.5 list-decimal list-inside">
+              <li>Install an authenticator app (Google Authenticator, Authy, 1Password, etc.)</li>
+              <li>Click "Generate QR Code" below</li>
+              <li>Scan the QR code or enter the secret manually</li>
+              <li>Enter the 6-digit code to confirm and activate</li>
+            </ol>
+          </div>
+
+          <div v-if="error" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+            <p class="text-sm text-red-400">{{ error }}</p>
+          </div>
+
+          <button
+            id="mfa-setup-btn"
+            @click="startSetup"
+            :disabled="loading"
+            class="btn-primary w-full justify-center"
+          >
+            <svg v-if="loading" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+            </svg>
+            {{ loading ? 'Generating…' : 'Generate QR Code' }}
+          </button>
+        </div>
+
+        <!-- ── Confirm step: show secret + TOTP input ─────────────────── -->
+        <div v-else-if="step === 'confirm'" class="space-y-5">
+          <!-- QR URI display -->
+          <div class="p-4 bg-slate-800/60 border border-slate-700 rounded-xl space-y-3">
+            <p class="text-xs font-semibold text-brand-400">Scan or enter manually in your authenticator app</p>
+
+            <!-- Rendered QR code via Google Charts API -->
+            <div class="flex justify-center">
+              <img
+                :src="`https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(qrUri)}&choe=UTF-8`"
+                alt="TOTP QR Code"
+                class="w-44 h-44 rounded-lg border border-slate-700 bg-white p-1"
+              />
+            </div>
+
+            <!-- Manual entry secret -->
+            <div>
+              <p class="text-[10px] text-slate-500 mb-1 uppercase tracking-wider font-semibold">Manual entry secret</p>
+              <code class="block text-xs font-mono text-brand-300 bg-brand-500/10 border border-brand-500/20 px-3 py-2 rounded-lg break-all select-all">{{ secret }}</code>
+            </div>
+
+            <!-- otpauth link (for mobile) -->
+            <a
+              :href="qrUri"
+              class="text-[11px] text-slate-500 hover:text-brand-400 transition-colors flex items-center gap-1 justify-center"
+            >
+              <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+              </svg>
+              Open in authenticator app
+            </a>
+          </div>
+
+          <!-- TOTP confirmation input -->
+          <div>
+            <label class="form-label" for="enable-totp">Enter code to activate MFA</label>
+            <input
+              id="enable-totp"
+              v-model="totpCode"
+              type="text"
+              inputmode="numeric"
+              maxlength="6"
+              autocomplete="one-time-code"
+              placeholder="000 000"
+              class="form-input text-center text-xl tracking-[0.4em] font-mono"
+              @keydown.enter.prevent="enableMFA"
+            />
+          </div>
+
+          <div v-if="error" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+            <p class="text-sm text-red-400">{{ error }}</p>
+          </div>
+
+          <div class="flex gap-3">
+            <button @click="step = 'idle'; error = ''; totpCode = ''" class="btn-ghost flex-1 justify-center">
+              Start over
+            </button>
+            <button
+              id="mfa-enable-btn"
+              @click="enableMFA"
+              :disabled="loading || totpCode.length !== 6"
+              class="btn-primary flex-1 justify-center"
+            >
+              <svg v-if="loading" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+              </svg>
+              {{ loading ? 'Activating…' : 'Activate MFA' }}
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <p class="text-center text-slate-700 text-xs mt-6">PMDI v1.0.0 · Internal Use Only</p>
+    </div>
+  </div>
+</template>

+ 40 - 0
frontend/tailwind.config.js

@@ -0,0 +1,40 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: [
+    './index.html',
+    './src/**/*.{vue,js,ts,jsx,tsx}',
+  ],
+  theme: {
+    extend: {
+      fontFamily: {
+        sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
+        mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'],
+      },
+      colors: {
+        brand: {
+          50:  '#eef2ff',
+          100: '#e0e7ff',
+          200: '#c7d2fe',
+          300: '#a5b4fc',
+          400: '#818cf8',
+          500: '#6366f1',
+          600: '#4f46e5',
+          700: '#4338ca',
+          800: '#3730a3',
+          900: '#312e81',
+          950: '#1e1b4b',
+        },
+      },
+      animation: {
+        'fade-in':    'fadeIn 0.2s ease-out',
+        'slide-up':   'slideUp 0.25s ease-out',
+        'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
+      },
+      keyframes: {
+        fadeIn:  { from: { opacity: '0' }, to: { opacity: '1' } },
+        slideUp: { from: { opacity: '0', transform: 'translateY(12px)' }, to: { opacity: '1', transform: 'translateY(0)' } },
+      },
+    },
+  },
+  plugins: [],
+}

+ 33 - 0
frontend/vite.config.js

@@ -0,0 +1,33 @@
+import { defineConfig, loadEnv } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), '')
+  // In Docker the backend is reachable at http://backend:8000 via the pmdi_net network.
+  // Override with VITE_API_TARGET env var for other environments.
+  const apiTarget = env.VITE_API_TARGET || 'http://localhost:8000'
+
+  return {
+    plugins: [vue()],
+
+    resolve: {
+      alias: {
+        '@': fileURLToPath(new URL('./src', import.meta.url)),
+      },
+    },
+
+    server: {
+      host:  '0.0.0.0',
+      port:  3000,
+      proxy: {
+        // Transparently forward all /api requests to the FastAPI backend
+        '/api': {
+          target:      apiTarget,
+          changeOrigin: true,
+          secure:      false,
+        },
+      },
+    },
+  }
+})