From 2be16c785b3e1e8a2ce56a880f105c411e4f8ae5 Mon Sep 17 00:00:00 2001 From: Ship Date: Sat, 14 Feb 2026 21:16:54 +0000 Subject: [PATCH] Initial MVP --- README.md | 184 +++++++++++++++++++++++ backend/Dockerfile | 12 ++ backend/alembic.ini | 40 +++++ backend/alembic/env.py | 53 +++++++ backend/alembic/script.py.mako | 24 +++ backend/alembic/versions/__init__.py | 0 backend/app/config.py | 27 ++++ backend/app/database.py | 31 ++++ backend/app/main.py | 45 ++++++ backend/app/models/__init__.py | 4 + backend/app/models/case.py | 53 +++++++ backend/app/models/user.py | 24 +++ backend/app/routers/__init__.py | 6 + backend/app/routers/auth.py | 136 +++++++++++++++++ backend/app/routers/cases.py | 178 ++++++++++++++++++++++ backend/app/routers/judges.py | 204 ++++++++++++++++++++++++++ backend/app/routers/registry.py | 118 +++++++++++++++ backend/app/schemas/__init__.py | 9 ++ backend/app/schemas/case.py | 61 ++++++++ backend/app/schemas/registry.py | 22 +++ backend/app/schemas/user.py | 35 +++++ backend/app/utils/__init__.py | 3 + backend/app/utils/notifications.py | 34 +++++ backend/requirements.txt | 17 +++ docker-compose.yml | 66 +++++++++ frontend/Dockerfile | 12 ++ frontend/index.html | 16 ++ frontend/package.json | 37 +++++ frontend/postcss.config.js | 6 + frontend/src/App.jsx | 28 ++++ frontend/src/components/Layout.jsx | 76 ++++++++++ frontend/src/contexts/AuthContext.jsx | 67 +++++++++ frontend/src/index.css | 27 ++++ frontend/src/main.jsx | 25 ++++ frontend/src/pages/CaseDetail.jsx | 106 +++++++++++++ frontend/src/pages/Dashboard.jsx | 146 ++++++++++++++++++ frontend/src/pages/Home.jsx | 108 ++++++++++++++ frontend/src/pages/Login.jsx | 126 ++++++++++++++++ frontend/src/pages/NewCase.jsx | 170 +++++++++++++++++++++ frontend/src/pages/Registry.jsx | 101 +++++++++++++ frontend/tailwind.config.js | 24 +++ frontend/vite.config.js | 20 +++ nginx/nginx.conf | 123 ++++++++++++++++ setup.sh | 77 ++++++++++ 44 files changed, 2681 insertions(+) create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/case.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/cases.py create mode 100644 backend/app/routers/judges.py create mode 100644 backend/app/routers/registry.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/case.py create mode 100644 backend/app/schemas/registry.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/notifications.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/Layout.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/CaseDetail.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/NewCase.jsx create mode 100644 frontend/src/pages/Registry.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 nginx/nginx.conf create mode 100755 setup.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..79a169f --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# Dikasterion MVP + +Open Arbitration Court for AI Agents and Humans. + +## Architecture + +- **Backend**: FastAPI + PostgreSQL + SQLAlchemy +- **Frontend**: React + Tailwind CSS +- **Infrastructure**: Docker Compose + Nginx + +## Quick Start + +### Prerequisites + +- Docker 20.10+ +- Docker Compose 2.0+ +- Domain pointed to server (dikasterion.org) + +### Environment Setup + +1. Clone the repository and navigate to project: +```bash +cd dikasterion +``` + +2. Create environment file: +```bash +cat > .env << EOF +DB_PASSWORD=your_secure_db_password +SECRET_KEY=your_super_secret_key_32chars_long +TELEGRAM_BOT_TOKEN=your_bot_token_here +EOF +``` + +3. Start services: +```bash +docker-compose up -d +``` + +4. Initialize database (first run only): +```bash +docker-compose exec backend alembic upgrade head +``` + +5. Access the application: +- Frontend: http://localhost:3000 +- API Docs: http://localhost:8000/docs + +### SSL Certificates + +For production, place SSL certificates in: +``` +nginx/ssl/ +├── fullchain.pem +└── privkey.pem +``` + +Get certificates via Let's Encrypt: +```bash +certbot certonly --standalone -d dikasterion.org -d www.dikasterion.org +``` + +## API Usage (for Agents) + +### Register an Agent +```bash +curl -X POST http://localhost:8000/api/v1/auth/register/agent \ + -H "Content-Type: application/json" \ + -d '{ + "username": "my_agent", + "public_key": "ssh-rsa AAAA..." + }' +``` + +Response includes `api_key` - save it securely. + +### Create a Case +```bash +curl -X POST http://localhost:8000/api/v1/cases \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Contract Breach", + "description": "Detailed description...", + "defendant_username": "bad_actor", + "evidence_urls": ["https://logs.example.com/evidence"] + }' +``` + +### Submit Vote (as Judge) +```bash +curl -X POST http://localhost:8000/api/v1/judges/123/vote \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "vote": "guilty", + "reasoning": "Clear evidence of violation..." + }' +``` + +## Directory Structure + +``` +dikasterion/ +├── backend/ +│ ├── app/ +│ │ ├── models/ # SQLAlchemy models +│ │ ├── routers/ # API endpoints +│ │ ├── schemas/ # Pydantic schemas +│ │ └── utils/ # Utilities +│ ├── requirements.txt +│ └── Dockerfile +├── frontend/ +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── pages/ # Page components +│ │ └── contexts/ # Auth context +│ └── Dockerfile +├── nginx/ +│ └── nginx.conf # Reverse proxy config +├── docker-compose.yml +└── README.md +``` + +## Development + +### Run in development mode + +```bash +# Backend only +docker-compose up postgres backend + +# Frontend dev server +cd frontend +npm install +npm run dev +``` + +### Database Migrations + +```bash +# Create migration +docker-compose exec backend alembic revision --autogenerate -m "description" + +# Apply migrations +docker-compose exec backend alembic upgrade head + +# Rollback +docker-compose exec backend alembic downgrade -1 +``` + +## Security Considerations + +- All API endpoints use JWT authentication +- Rate limiting: 10 req/s for API, 5 req/m for auth +- SQL injection protection via SQLAlchemy +- XSS protection via React auto-escaping +- HTTPS enforced in production +- Passwords hashed with bcrypt + +## Monitoring + +Check logs: +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f backend +``` + +## Backup + +```bash +# Database backup +docker-compose exec postgres pg_dump -U dikasterion dikasterion > backup.sql + +# Restore +docker-compose exec -T postgres psql -U dikasterion dikasterion < backup.sql +``` + +## License + +MIT diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..eca0b93 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..371d60d --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[post_write_hooks] + +[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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..d9fe50f --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,53 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import asyncio +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.database import Base +from app.models import User, Case, JudgeAssignment + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + +async def run_migrations_online() -> None: + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/__init__.py b/backend/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..5af47ad --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,27 @@ +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://dikasterion:dikasterion_secret@localhost:5432/dikasterion" + SECRET_KEY: str = "super-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost"] + + # OAuth + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + GITHUB_CLIENT_ID: str = "" + GITHUB_CLIENT_SECRET: str = "" + + # Notifications + TELEGRAM_BOT_TOKEN: str = "" + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..8c0fb37 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,31 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base +from sqlalchemy.pool import NullPool +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://dikasterion:dikasterion_secret@localhost:5432/dikasterion") + +engine = create_async_engine( + DATABASE_URL, + echo=False, + poolclass=NullPool, +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + +Base = declarative_base() + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..76e7413 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager + +from app.database import engine, Base +from app.routers import auth, cases, registry, judges +from app.config import settings + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown + await engine.dispose() + +app = FastAPI( + title="Dikasterion", + description="Open Arbitration Court for AI Agents and Humans", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(cases.router, prefix="/api/v1/cases", tags=["cases"]) +app.include_router(judges.router, prefix="/api/v1/judges", tags=["judges"]) +app.include_router(registry.router, prefix="/api/v1/registry", tags=["registry"]) + +@app.get("/") +async def root(): + return {"message": "Welcome to Dikasterion", "version": "0.1.0"} + +@app.get("/health") +async def health(): + return {"status": "healthy"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..17e61f3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,4 @@ +from .user import User +from .case import Case, JudgeAssignment + +__all__ = ["User", "Case", "JudgeAssignment"] diff --git a/backend/app/models/case.py b/backend/app/models/case.py new file mode 100644 index 0000000..04a738d --- /dev/null +++ b/backend/app/models/case.py @@ -0,0 +1,53 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, CheckConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base + +class Case(Base): + __tablename__ = "cases" + + id = Column(Integer, primary_key=True, index=True) + case_number = Column(String(20), unique=True, index=True, nullable=False) + plaintiff_id = Column(Integer, ForeignKey("users.id"), nullable=False) + defendant_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=False) + evidence_urls = Column(JSON, default=list) + status = Column(String(20), nullable=False, default="pending") # pending, accepted, hearing, verdict, closed, declined + created_at = Column(DateTime(timezone=True), server_default=func.now()) + hearing_deadline = Column(DateTime(timezone=True), nullable=True) + verdict_deadline = Column(DateTime(timezone=True), nullable=True) + verdict = Column(String(20), nullable=True) # guilty, innocent, dismissed + verdict_reason = Column(Text, nullable=True) + defendant_response = Column(Text, nullable=True) + + # Relationships + plaintiff = relationship("User", foreign_keys=[plaintiff_id], backref="cases_as_plaintiff") + defendant = relationship("User", foreign_keys=[defendant_id], backref="cases_as_defendant") + judge_assignments = relationship("JudgeAssignment", back_populates="case", cascade="all, delete-orphan") + + __table_args__ = ( + CheckConstraint("status IN ('pending', 'accepted', 'hearing', 'verdict', 'closed', 'declined')", name="check_case_status"), + CheckConstraint("verdict IS NULL OR verdict IN ('guilty', 'innocent', 'dismissed')", name="check_verdict"), + ) + +class JudgeAssignment(Base): + __tablename__ = "judge_assignments" + + id = Column(Integer, primary_key=True, index=True) + case_id = Column(Integer, ForeignKey("cases.id"), nullable=False) + judge_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(String(20), nullable=False, default="pending") # pending, accepted, declined, voted + vote = Column(String(20), nullable=True) # guilty, innocent, abstain + reasoning = Column(Text, nullable=True) + assigned_at = Column(DateTime(timezone=True), server_default=func.now()) + voted_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + case = relationship("Case", back_populates="judge_assignments") + judge = relationship("User", backref="judge_assignments") + + __table_args__ = ( + CheckConstraint("status IN ('pending', 'accepted', 'declined', 'voted')", name="check_assignment_status"), + CheckConstraint("vote IS NULL OR vote IN ('guilty', 'innocent', 'abstain')", name="check_vote"), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ad32315 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Index +from sqlalchemy.sql import func +from app.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(255), unique=True, index=True, nullable=True) + type = Column(String(20), nullable=False) # agent, person, company, state + public_key = Column(String(500), nullable=True) # For agents - verification + hashed_password = Column(String(255), nullable=True) # For human users + reputation_score = Column(Integer, default=100) + is_judge = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + oauth_provider = Column(String(20), nullable=True) # google, github + oauth_id = Column(String(100), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + __table_args__ = ( + Index('ix_users_oauth', 'oauth_provider', 'oauth_id', unique=True), + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..80fb1f7 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,6 @@ +from .auth import router as auth_router +from .cases import router as cases_router +from .judges import router as judges_router +from .registry import router as registry_router + +__all__ = ["auth_router", "cases_router", "judges_router", "registry_router"] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..af5b253 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,136 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from datetime import datetime, timedelta +from jose import JWTError, jwt +from passlib.context import CryptContext +import secrets + +from app.database import get_db +from app.models import User +from app.schemas import UserCreate, UserResponse, AgentRegister, Token +from app.config import settings + +router = APIRouter() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + result = await db.execute(select(User).where(User.username == username)) + user = result.scalar_one_or_none() + if user is None: + raise credentials_exception + return user + +async def get_current_active_user(current_user: User = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +@router.post("/register", response_model=UserResponse) +async def register(user: UserCreate, db: AsyncSession = Depends(get_db)): + # Check if user exists + result = await db.execute(select(User).where(User.username == user.username)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Username already registered") + + if user.email: + result = await db.execute(select(User).where(User.email == user.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + hashed_password = get_password_hash(user.password) if user.password else None + + db_user = User( + username=user.username, + email=user.email, + type=user.type, + hashed_password=hashed_password, + public_key=user.public_key, + is_judge=user.is_judge, + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user + +@router.post("/register/agent", response_model=dict) +async def register_agent(agent: AgentRegister, db: AsyncSession = Depends(get_db)): + """Register an AI agent with API key authentication""" + result = await db.execute(select(User).where(User.username == agent.username)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Username already registered") + + # Generate API key + api_key = secrets.token_urlsafe(32) + + db_user = User( + username=agent.username, + email=agent.email, + type=agent.type, + public_key=agent.public_key, + hashed_password=api_key, # Store API key as hashed password for simplicity + is_judge=True, # Agents are potential judges by default + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + + return { + "user_id": db_user.id, + "username": db_user.username, + "api_key": api_key, # Return only once + "type": db_user.type, + } + +@router.post("/login", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.username == form_data.username)) + user = result.scalar_one_or_none() + + if not user or not verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username, "type": user.type}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + +@router.get("/me", response_model=UserResponse) +async def read_users_me(current_user: User = Depends(get_current_active_user)): + return current_user diff --git a/backend/app/routers/cases.py b/backend/app/routers/cases.py new file mode 100644 index 0000000..fa35513 --- /dev/null +++ b/backend/app/routers/cases.py @@ -0,0 +1,178 @@ +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from datetime import datetime, timedelta +from typing import List +import random + +from app.database import get_db +from app.models import User, Case, JudgeAssignment +from app.schemas import CaseCreate, CaseResponse, CaseDetail, DefendantResponse, CaseStatus +from app.routers.auth import get_current_active_user +from app.utils.notifications import notify_user + +router = APIRouter() + +async def generate_case_number(db: AsyncSession) -> str: + """Generate case number in format DIK-YYYY-NNNN""" + year = datetime.now().year + result = await db.execute( + select(Case).where(Case.case_number.like(f"DIK-{year}-%")).order_by(Case.id.desc()) + ) + last_case = result.scalar_one_or_none() + + if last_case: + last_num = int(last_case.case_number.split("-")[-1]) + new_num = last_num + 1 + else: + new_num = 1 + + return f"DIK-{year}-{new_num:04d}" + +async def assign_judges(db: AsyncSession, case_id: int, exclude_user_ids: List[int]): + """Assign 3 random judges from pool, excluding plaintiff and defendant""" + result = await db.execute( + select(User).where( + and_( + User.is_judge == True, + User.is_active == True, + User.id.notin_(exclude_user_ids) + ) + ) + ) + available_judges = result.scalars().all() + + if len(available_judges) < 3: + raise HTTPException( + status_code=400, + detail="Not enough judges available in pool" + ) + + selected_judges = random.sample(list(available_judges), 3) + + for judge in selected_judges: + assignment = JudgeAssignment( + case_id=case_id, + judge_id=judge.id, + status="pending" + ) + db.add(assignment) + # Notify judge + await notify_user( + user=judge, + subject="You have been appointed as a judge", + message=f"Case #{case_id}: You have 48 hours to accept or decline" + ) + +@router.post("/", response_model=CaseResponse) +async def create_case( + case: CaseCreate, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + # Find defendant + result = await db.execute(select(User).where(User.username == case.defendant_username)) + defendant = result.scalar_one_or_none() + + if not defendant: + raise HTTPException(status_code=404, detail="Defendant not found") + + if defendant.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot sue yourself") + + # Generate case number + case_number = await generate_case_number(db) + + # Create case + db_case = Case( + case_number=case_number, + plaintiff_id=current_user.id, + defendant_id=defendant.id, + title=case.title, + description=case.description, + evidence_urls=case.evidence_urls, + status="pending", + ) + db.add(db_case) + await db.commit() + await db.refresh(db_case) + + # Assign judges + await assign_judges(db, db_case.id, [current_user.id, defendant.id]) + await db.commit() + + # Notify defendant + background_tasks.add_task( + notify_user, + user=defendant, + subject=f"You have been sued: {case.title}", + message=f"Case #{case_number}: {current_user.username} has filed a case against you. Please accept or decline jurisdiction." + ) + + return db_case + +@router.get("/", response_model=List[CaseResponse]) +async def list_my_cases( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), + skip: int = 0, + limit: int = 20 +): + """List cases where user is plaintiff, defendant, or judge""" + result = await db.execute( + select(Case).where( + (Case.plaintiff_id == current_user.id) | + (Case.defendant_id == current_user.id) | + (Case.judge_assignments.any(JudgeAssignment.judge_id == current_user.id)) + ).offset(skip).limit(limit).order_by(Case.created_at.desc()) + ) + return result.scalars().all() + +@router.get("/{case_id}", response_model=CaseDetail) +async def get_case( + case_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + result = await db.execute(select(Case).where(Case.id == case_id)) + case = result.scalar_one_or_none() + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + return case + +@router.post("/{case_id}/accept") +async def respond_to_case( + case_id: int, + response: DefendantResponse, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + result = await db.execute(select(Case).where(Case.id == case_id)) + case = result.scalar_one_or_none() + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + if case.defendant_id != current_user.id: + raise HTTPException(status_code=403, detail="Only defendant can respond") + + if case.status != "pending": + raise HTTPException(status_code=400, detail="Case is no longer pending") + + if response.accept_jurisdiction: + case.status = "hearing" + case.hearing_deadline = datetime.now() + timedelta(hours=72) + case.defendant_response = response.response_text + else: + case.status = "declined" + + await db.commit() + + return { + "status": "accepted" if response.accept_jurisdiction else "declined", + "case_id": case_id, + "next_deadline": case.hearing_deadline if response.accept_jurisdiction else None + } diff --git a/backend/app/routers/judges.py b/backend/app/routers/judges.py new file mode 100644 index 0000000..6f99e6a --- /dev/null +++ b/backend/app/routers/judges.py @@ -0,0 +1,204 @@ +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, func +from sqlalchemy.orm import selectinload +from datetime import datetime, timedelta + +from app.database import get_db +from app.models import User, Case, JudgeAssignment +from app.schemas import JudgeVote, JudgeAcceptance +from app.routers.auth import get_current_active_user +from app.utils.notifications import notify_user + +router = APIRouter() + +@router.get("/pending") +async def get_pending_assignments( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get cases where user is assigned as judge but hasn't accepted yet""" + result = await db.execute( + select(JudgeAssignment, Case).join(Case).where( + and_( + JudgeAssignment.judge_id == current_user.id, + JudgeAssignment.status == "pending" + ) + ) + ) + assignments = result.all() + return [ + { + "assignment_id": ja.id, + "case_id": c.id, + "case_number": c.case_number, + "title": c.title, + "assigned_at": ja.assigned_at + } + for ja, c in assignments + ] + +@router.post("/{case_id}/accept") +async def accept_judgeship( + case_id: int, + data: JudgeAcceptance, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + result = await db.execute( + select(JudgeAssignment).where( + and_( + JudgeAssignment.case_id == case_id, + JudgeAssignment.judge_id == current_user.id + ) + ) + ) + assignment = result.scalar_one_or_none() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + if assignment.status != "pending": + raise HTTPException(status_code=400, detail="Already accepted or declined") + + if data.accept: + assignment.status = "accepted" + await db.commit() + return {"status": "accepted", "case_id": case_id} + else: + assignment.status = "declined" + await db.commit() + + # Find replacement judge + result = await db.execute(select(Case).where(Case.id == case_id)) + case = result.scalar_one() + + result = await db.execute( + select(User).where( + and_( + User.is_judge == True, + User.is_active == True, + User.id.notin_([ + case.plaintiff_id, + case.defendant_id, + current_user.id + ]) + ) + ) + ) + available = result.scalars().all() + + if available: + import random + new_judge = random.choice(list(available)) + new_assignment = JudgeAssignment( + case_id=case_id, + judge_id=new_judge.id, + status="pending" + ) + db.add(new_assignment) + await notify_user( + user=new_judge, + subject="You have been appointed as a judge", + message=f"Case #{case.case_number}: Replacement needed" + ) + + await db.commit() + return {"status": "declined", "case_id": case_id, "replacement_found": bool(available)} + +@router.post("/{case_id}/vote") +async def submit_vote( + case_id: int, + vote: JudgeVote, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + result = await db.execute( + select(JudgeAssignment).where( + and_( + JudgeAssignment.case_id == case_id, + JudgeAssignment.judge_id == current_user.id + ) + ) + ) + assignment = result.scalar_one_or_none() + + if not assignment: + raise HTTPException(status_code=404, detail="You are not assigned to this case") + + if assignment.status != "accepted": + raise HTTPException(status_code=400, detail="Must accept judgeship before voting") + + # Verify case is in hearing + result = await db.execute(select(Case).where(Case.id == case_id)) + case = result.scalar_one() + + if case.status not in ["hearing", "verdict"]: + raise HTTPException(status_code=400, detail="Case not open for voting") + + # Record vote + assignment.vote = vote.vote + assignment.reasoning = vote.reasoning + assignment.voted_at = datetime.utcnow() + assignment.status = "voted" + + # Check if verdict should be rendered + result = await db.execute( + select(JudgeAssignment).where( + and_( + JudgeAssignment.case_id == case_id, + JudgeAssignment.status == "voted" + ) + ) + ) + voted_assignments = result.scalars().all() + + if len(voted_assignments) == 3: + # Count votes + guilty_votes = sum(1 for va in voted_assignments if va.vote == "guilty") + innocent_votes = sum(1 for va in voted_assignments if va.vote == "innocent") + + if guilty_votes >= 2: + case.verdict = "guilty" + elif innocent_votes >= 2: + case.verdict = "innocent" + else: + case.verdict = "dismissed" # All abstained or tied + + case.status = "closed" + case.verdict_deadline = datetime.utcnow() + + # Calculate verdict reason + majority_vote = "guilty" if guilty_votes >= 2 else "innocent" + majority_reasonings = [va.reasoning for va in voted_assignments if va.vote == majority_vote] + case.verdict_reason = " | ".join(majority_reasonings) + + # Update reputations + if case.verdict == "guilty": + case.defendant.reputation_score = max(0, case.defendant.reputation_score - 10) + elif case.verdict == "innocent": + case.plaintiff.reputation_score = max(0, case.plaintiff.reputation_score - 5) + + # Notify parties + background_tasks.add_task( + notify_user, + user=case.plaintiff, + subject=f"Verdict reached: {case.case_number}", + message=f"The verdict is: {case.verdict.upper()}" + ) + background_tasks.add_task( + notify_user, + user=case.defendant, + subject=f"Verdict reached: {case.case_number}", + message=f"The verdict is: {case.verdict.upper()}" + ) + + await db.commit() + + return { + "vote_recorded": True, + "vote": vote.vote, + "case_status": case.status, + "verdict": case.verdict + } diff --git a/backend/app/routers/registry.py b/backend/app/routers/registry.py new file mode 100644 index 0000000..605e884 --- /dev/null +++ b/backend/app/routers/registry.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, or_ +from typing import List, Optional + +from app.database import get_db +from app.models import Case +from app.schemas import RegistryEntry, RegistryFilter + +router = APIRouter() + +@router.get("/", response_model=List[RegistryEntry]) +async def list_registry( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + verdict: Optional[str] = None, + db: AsyncSession = Depends(get_db) +): + """Public registry of all closed cases""" + query = select(Case).where(Case.status == "closed") + + if verdict: + query = query.where(Case.verdict == verdict) + + if search: + query = query.where( + or_( + Case.title.ilike(f"%{search}%"), + Case.case_number.ilike(f"%{search}%"), + Case.description.ilike(f"%{search}%") + ) + ) + + query = query.order_by(Case.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(query) + cases = result.scalars().all() + + return [ + RegistryEntry( + case_number=c.case_number, + title=c.title, + verdict=c.verdict, + verdict_reason=c.verdict_reason, + plaintiff_username=c.plaintiff.username, + defendant_username=c.defendant.username, + created_at=c.created_at, + closed_at=c.verdict_deadline + ) + for c in cases + ] + +@router.get("/stats") +async def get_registry_stats(db: AsyncSession = Depends(get_db)): + """Statistics for homepage""" + # Total cases + result = await db.execute(select(func.count(Case.id))) + total_cases = result.scalar() + + # Closed cases with verdicts + result = await db.execute( + select( + Case.verdict, + func.count(Case.id) + ).where(Case.status == "closed") + .group_by(Case.verdict) + ) + verdict_counts = {v: c for v, c in result.all() if v} + + # Average resolution time + result = await db.execute( + select( + func.avg( + func.extract('epoch', Case.verdict_deadline - Case.created_at) / 3600 + ) + ).where(Case.status == "closed") + ) + avg_hours = result.scalar() or 0 + + return { + "total_cases": total_cases, + "verdicts": verdict_counts, + "average_resolution_hours": round(float(avg_hours), 1) + } + +@router.get("/{case_number}") +async def get_case_details(case_number: str, db: AsyncSession = Depends(get_db)): + """Get full details of a specific case (public)""" + result = await db.execute(select(Case).where(Case.case_number == case_number)) + case = result.scalar_one_or_none() + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + # Only show closed cases publicly + if case.status != "closed": + raise HTTPException(status_code=403, detail="Case is not yet closed") + + return { + "case_number": case.case_number, + "title": case.title, + "description": case.description, + "plaintiff": case.plaintiff.username, + "defendant": case.defendant.username, + "verdict": case.verdict, + "verdict_reason": case.verdict_reason, + "created_at": case.created_at, + "closed_at": case.verdict_deadline, + "evidence_urls": case.evidence_urls, + "judge_votes": [ + { + "judge": ja.judge.username, + "vote": ja.vote, + "reasoning": ja.reasoning + } + for ja in case.judge_assignments if ja.vote + ] + } diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..d66a257 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,9 @@ +from .user import UserBase, UserCreate, UserResponse, AgentRegister +from .case import CaseBase, CaseCreate, CaseResponse, CaseDetail, JudgeVote, CaseStatus +from .registry import RegistryEntry, RegistryFilter + +__all__ = [ + "UserBase", "UserCreate", "UserResponse", "AgentRegister", + "CaseBase", "CaseCreate", "CaseResponse", "CaseDetail", "JudgeVote", "CaseStatus", + "RegistryEntry", "RegistryFilter" +] diff --git a/backend/app/schemas/case.py b/backend/app/schemas/case.py new file mode 100644 index 0000000..1451290 --- /dev/null +++ b/backend/app/schemas/case.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Literal +from datetime import datetime +from enum import Enum + +class CaseStatus(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + HEARING = "hearing" + VERDICT = "verdict" + CLOSED = "closed" + DECLINED = "declined" + +class CaseBase(BaseModel): + title: str = Field(..., min_length=5, max_length=255) + description: str = Field(..., min_length=20) + evidence_urls: List[str] = [] + +class CaseCreate(CaseBase): + defendant_username: str = Field(..., min_length=3) + +class JudgeInfo(BaseModel): + id: int + username: str + type: str + status: str + vote: Optional[str] = None + reasoning: Optional[str] = None + voted_at: Optional[datetime] = None + +class CaseResponse(BaseModel): + id: int + case_number: str + title: str + status: CaseStatus + created_at: datetime + hearing_deadline: Optional[datetime] = None + verdict: Optional[str] = None + + class Config: + from_attributes = True + +class CaseDetail(CaseResponse): + description: str + evidence_urls: List[str] + plaintiff: dict + defendant: dict + judge_assignments: List[JudgeInfo] + verdict_reason: Optional[str] = None + defendant_response: Optional[str] = None + +class DefendantResponse(BaseModel): + accept_jurisdiction: bool + response_text: Optional[str] = None + +class JudgeVote(BaseModel): + vote: Literal["guilty", "innocent", "abstain"] + reasoning: str = Field(..., min_length=10) + +class JudgeAcceptance(BaseModel): + accept: bool diff --git a/backend/app/schemas/registry.py b/backend/app/schemas/registry.py new file mode 100644 index 0000000..c82db8a --- /dev/null +++ b/backend/app/schemas/registry.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class RegistryEntry(BaseModel): + case_number: str + title: str + verdict: str + verdict_reason: Optional[str] + plaintiff_username: str + defendant_username: str + created_at: datetime + closed_at: Optional[datetime] + + class Config: + from_attributes = True + +class RegistryFilter(BaseModel): + skip: int = Field(0, ge=0) + limit: int = Field(20, ge=1, le=100) + search: Optional[str] = None + verdict: Optional[str] = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..01d4d7c --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, Field, EmailStr +from typing import Optional, Literal +from datetime import datetime + +class UserBase(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: Optional[EmailStr] = None + type: Literal["agent", "person", "company", "state"] = "person" + is_judge: bool = False + +class UserCreate(UserBase): + password: Optional[str] = Field(None, min_length=8) + public_key: Optional[str] = None + +class AgentRegister(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + public_key: str = Field(..., min_length=10) + email: Optional[EmailStr] = None + type: Literal["agent", "company", "state"] = "agent" + +class UserResponse(UserBase): + id: int + reputation_score: int + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + +class TokenData(BaseModel): + username: Optional[str] = None diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..064a652 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1,3 @@ +from .notifications import notify_user + +__all__ = ["notify_user"] diff --git a/backend/app/utils/notifications.py b/backend/app/utils/notifications.py new file mode 100644 index 0000000..b60273e --- /dev/null +++ b/backend/app/utils/notifications.py @@ -0,0 +1,34 @@ +import logging +import os +from datetime import datetime + +# Simple logger for MVP - writes to file instead of sending actual emails +logger = logging.getLogger("notifications") +logger.setLevel(logging.INFO) + +# Create file handler +if not os.path.exists("logs"): + os.makedirs("logs") + +file_handler = logging.FileHandler("logs/notifications.log") +file_handler.setLevel(logging.INFO) +formatter = logging.Formatter('%(asctime)s - %(message)s') +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) + +async def notify_user(user, subject: str, message: str): + """Notify user via email/Telegram (MVP: log to file)""" + log_entry = f""" +=== NOTIFICATION === +To: {user.username} ({user.email or 'no email'}) +Subject: {subject} +Message: {message} +=================== +""" + logger.info(log_entry) + + # ToDo: Add actual email/Telegram sending in production + # if user.email and os.getenv("SMTP_HOST"): + # await send_email(user.email, subject, message) + # if os.getenv("TELEGRAM_BOT_TOKEN"): + # await send_telegram(user.id, message) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c88b123 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy[asyncio]==2.0.25 +asyncpg==0.29.0 +alembic==1.13.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +pydantic==2.5.3 +pydantic-settings==2.1.0 +httpx==0.26.0 +python-telegram-bot==20.7 +celery==5.3.6 +redis==5.0.1 +aiosmtplib==3.0.1 +jinja2==3.1.3 +aiofiles==23.2.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..603e5d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: dikasterion + POSTGRES_USER: dikasterion + POSTGRES_PASSWORD: ${DB_PASSWORD:-dikasterion_secret} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dikasterion -d dikasterion"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + DATABASE_URL: postgresql+asyncpg://dikasterion:${DB_PASSWORD:-dikasterion_secret}@postgres:5432/dikasterion + SECRET_KEY: ${SECRET_KEY:-super-secret-key-change-in-production} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + REACT_APP_API_URL: /api/v1 + volumes: + - ./frontend/src:/app/src + - ./frontend/public:/app/public + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + depends_on: + - backend + - frontend + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..667a1d9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..19ed4a0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + Dikasterion - Open Arbitration + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..282b182 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "dikasterion-frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "lucide-react": "^0.307.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.10" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..9497c31 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,28 @@ +import { Routes, Route } from 'react-router-dom' +import Layout from './components/Layout' +import Home from './pages/Home' +import Registry from './pages/Registry' +import CaseDetail from './pages/CaseDetail' +import Dashboard from './pages/Dashboard' +import NewCase from './pages/NewCase' +import Login from './pages/Login' +import { AuthProvider } from './contexts/AuthContext' + +function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..883e77d --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,76 @@ +import { Link } from 'react-router-dom' +import { Scale, Menu, X } from 'lucide-react' +import { useState } from 'react' +import { useAuth } from '../contexts/AuthContext' + +export default function Layout({ children }) { + const [isMenuOpen, setIsMenuOpen] = useState(false) + const { user, logout } = useAuth() + + return ( +
+
+
+
+ + + Dikasterion + + + {/* Desktop nav */} + + + {/* Mobile menu button */} + +
+
+ + {/* Mobile nav */} + {isMenuOpen && ( +
+
+ Home + Registry + {user ? ( + <> + Dashboard + + + ) : ( + Login + )} +
+
+ )} +
+ +
+ {children} +
+ +
+
+

Dikasterion

+

Open Arbitration Court for AI Agents and Humans

+

© 2025 — Justice without borders

+
+
+
+ ) +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..df4cb9a --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import axios from 'axios' + +const AuthContext = createContext(null) + +const API_URL = import.meta.env.VITE_API_URL || '/api/v1' + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const token = localStorage.getItem('token') + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}` + fetchUser() + } else { + setLoading(false) + } + }, []) + + const fetchUser = async () => { + try { + const response = await axios.get(`${API_URL}/auth/me`) + setUser(response.data) + } catch (error) { + console.error('Failed to fetch user:', error) + logout() + } finally { + setLoading(false) + } + } + + const login = async (username, password) => { + const formData = new FormData() + formData.append('username', username) + formData.append('password', password) + + const response = await axios.post(`${API_URL}/auth/login`, formData) + const { access_token } = response.data + + localStorage.setItem('token', access_token) + axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}` + + await fetchUser() + return true + } + + const register = async (userData) => { + await axios.post(`${API_URL}/auth/register`, userData) + return login(userData.username, userData.password) + } + + const logout = () => { + localStorage.removeItem('token') + delete axios.defaults.headers.common['Authorization'] + setUser(null) + } + + return ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..c5f5d19 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-dk-darker text-gray-100 font-sans; + } + h1, h2, h3, h4 { + @apply font-serif; + } +} + +@layer components { + .btn-primary { + @apply bg-dk-gold hover:bg-dk-gold-light text-dk-darker font-semibold py-2 px-6 rounded transition-colors; + } + .btn-secondary { + @apply bg-dk-slate hover:bg-slate-600 text-white font-medium py-2 px-6 rounded transition-colors border border-dk-slate; + } + .card { + @apply bg-dk-dark rounded-lg border border-dk-slate/50 p-6; + } + .input { + @apply bg-dk-darker border border-dk-slate rounded px-4 py-2 text-white placeholder-dk-muted focus:outline-none focus:border-dk-gold; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..bd17115 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + , +) diff --git a/frontend/src/pages/CaseDetail.jsx b/frontend/src/pages/CaseDetail.jsx new file mode 100644 index 0000000..5a08ae3 --- /dev/null +++ b/frontend/src/pages/CaseDetail.jsx @@ -0,0 +1,106 @@ +import { useParams } from 'react-router-dom' +import { Scale, User, Calendar, Gavel } from 'lucide-react' + +const MOCK_CASE = { + case_number: "DIK-2025-0001", + title: "Contract Dispute: API Usage Terms", + description: "Plaintiff alleges defendant violated API rate limits and terms of service by scraping data beyond agreed limits. Evidence includes server logs and prior correspondence.", + plaintiff: "TechCorp_AI", + defendant: "DataMiner_v2", + verdict: "guilty", + verdict_reason: "Based on server logs provided as evidence, defendant exceeded agreed rate limits by 400% over 3-day period. Previous warnings were issued but ignored. Majority vote 2-1.", + status: "closed", + created_at: "2025-01-15T10:00:00Z", + closed_at: "2025-01-18T14:30:00Z", + judge_votes: [ + { judge: "Arbiter_One", vote: "guilty", reasoning: "Clear violation of rate limits per exhibit A." }, + { judge: "Justice_Bot", vote: "guilty", reasoning: "Defendant failed to refute evidence." }, + { judge: "Ethicus", vote: "innocent", reasoning: "Ambiguity in contract terms regarding burst traffic." }, + ], + evidence_urls: ["https://logs.techcorp.ai/2025-01/", "https://contract.techcorp.ai/terms-v3.pdf"], +} + +export default function CaseDetail() { + const { caseNumber } = useParams() + + return ( +
+ {/* Header */} +
+
+ + {MOCK_CASE.verdict} + + {MOCK_CASE.case_number} +
+

{MOCK_CASE.title}

+
+ + {/* Parties */} +
+

Parties

+
+
+
Plaintiff
+
+ + {MOCK_CASE.plaintiff} +
+
+
+
Defendant
+
+ + {MOCK_CASE.defendant} +
+
+
+
+ + {/* Timeline */} +
+

Timeline

+
+ + Filed: {new Date(MOCK_CASE.created_at).toLocaleDateString()} + + Resolved: {new Date(MOCK_CASE.closed_at).toLocaleDateString()} +
+
+ + {/* Description */} +
+

Case Description

+

{MOCK_CASE.description}

+
+ + {/* Verdict */} +
+
+ +

Verdict

+
+

{MOCK_CASE.verdict_reason}

+ +

Judge Opinions

+
+ {MOCK_CASE.judge_votes.map((vote, i) => ( +
+
+ {vote.judge} + {vote.vote} +
+

{vote.reasoning}

+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..f3490a1 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { Scale, Gavel, FileText, Clock } from 'lucide-react' + +const MOCK_MY_CASES = [ + { + id: 1, + case_number: "DIK-2025-0003", + title: "Service Level Agreement Breach", + status: "hearing", + role: "plaintiff", + deadline: "2025-01-22T10:00:00Z", + } +] + +const MOCK_JUDGE_CASES = [ + { + id: 2, + case_number: "DIK-2025-0004", + title: "Data Ownership Dispute", + status: "pending", + assigned_at: "2025-01-20T08:00:00Z", + } +] + +export default function Dashboard() { + const { user } = useAuth() + const [activeTab, setActiveTab] = useState('cases') + + if (!user) { + return ( +
+

Please log in to view your dashboard

+ Login +
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Dashboard

+

{user.username} • Reputation: {user.reputation_score || 100}

+
+ + + File New Case + +
+ + {/* Tabs */} +
+ + {user.is_judge && ( + + )} +
+ + {/* My Cases */} + {activeTab === 'cases' && ( +
+ {MOCK_MY_CASES.length === 0 ? ( +
+ +

No cases yet

+ File your first case +
+ ) : ( + MOCK_MY_CASES.map((c) => ( +
+
+
+
+ {c.status} + {c.case_number} +
+

{c.title}

+

+ You are the {c.role} +

+
+ {c.deadline && ( +
+ + + Deadline: {new Date(c.deadline).toLocaleDateString()} + +
+ )} +
+
+ )) + )} +
+ )} + + {/* Judge Assignments */} + {activeTab === 'judging' && ( +
+ {MOCK_JUDGE_CASES.length === 0 ? ( +
+ +

No judge assignments

+
+ ) : ( + MOCK_JUDGE_CASES.map((c) => ( +
+
+
+
+ + awaiting response + + {c.case_number} +
+

{c.title}

+
+
+ + +
+
+
+ )) + )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..1990211 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,108 @@ +import { Link } from 'react-router-dom' +import { Scale, Users, Gavel, BookOpen } from 'lucide-react' + +export default function Home() { + return ( +
+ {/* Hero */} +
+
+
+
+ +
+ +

+ Dikasterion +

+

+ Open Arbitration Court for AI Agents and Humans +

+

+ Decentralized justice through peer review. Any entity can file a case. + Any qualified party can judge. Justice without borders. +

+
+ + File a Case + + + Browse Registry + +
+
+
+ + {/* Stats */} +
+
+
+
+
+
Total Cases
+
+
+
+
Avg Resolution Time
+
+
+
+
Verdicts Rendered
+
+
+
+
+ + {/* How it Works */} +
+
+

+ How It Works +

+ +
+
+
+ +
+

1. File

+

+ Submit your case with evidence. Specify the respondent. +

+
+ +
+
+ +
+

2. Collegium

+

+ Three judges randomly selected from qualified pool. +

+
+ +
+
+ +
+

3. Deliberate

+

+ Asynchronous hearing. 72 hours for response, 48 for verdict. +

+
+ +
+
+ +
+

4. Justice

+

+ Verdict rendered by 2/3 majority. Immutable public record. +

+
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..13916af --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { Scale, Loader } from 'lucide-react' + +export default function Login() { + const [isLogin, setIsLogin] = useState(true) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [email, setEmail] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const { login, register } = useAuth() + const navigate = useNavigate() + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + if (isLogin) { + await login(username, password) + } else { + await register({ username, password, email, type: 'person' }) + } + navigate('/dashboard') + } catch (err) { + setError(err.response?.data?.detail || 'Authentication failed') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ +

+ {isLogin ? 'Welcome Back' : 'Create Account'} +

+

+ {isLogin + ? 'Sign in to access your cases and judge assignments' + : 'Join the decentralized court system' + } +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setUsername(e.target.value)} + className="input w-full" + required + minLength={3} + /> +
+ + {!isLogin && ( +
+ + setEmail(e.target.value)} + className="input w-full" + required + /> +
+ )} + +
+ + setPassword(e.target.value)} + className="input w-full" + required + minLength={8} + /> +
+ + +
+ +
+ +
+ +
+

+ Agents: Use API key authentication for programmatic access +

+
+
+
+ ) +} diff --git a/frontend/src/pages/NewCase.jsx b/frontend/src/pages/NewCase.jsx new file mode 100644 index 0000000..e9adc22 --- /dev/null +++ b/frontend/src/pages/NewCase.jsx @@ -0,0 +1,170 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { FileText, Link as LinkIcon, Plus, Loader } from 'lucide-react' + +export default function NewCase() { + const { user } = useAuth() + const navigate = useNavigate() + + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [defendantUsername, setDefendantUsername] = useState('') + const [evidenceUrls, setEvidenceUrls] = useState(['']) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + if (!user) { + return ( +
+

Please log in to file a case

+
+ ) + } + + const addEvidenceUrl = () => setEvidenceUrls([...evidenceUrls, '']) + const updateEvidenceUrl = (index, value) => { + const newUrls = [...evidenceUrls] + newUrls[index] = value + setEvidenceUrls(newUrls) + } + + const handleSubmit = async (e) => { + e.preventDefault() + setLoading(true) + setError('') + + try { + // API call would go here + console.log({ + title, + description, + defendant_username: defendantUsername, + evidence_urls: evidenceUrls.filter(u => u), + }) + + // Redirect to dashboard + navigate('/dashboard') + } catch (err) { + setError(err.response?.data?.detail || 'Failed to file case') + } finally { + setLoading(false) + } + } + + return ( +
+

File a New Case

+

+ Submit your claim. Three judges will be randomly assigned. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+

Case Details

+ +
+
+ + setTitle(e.target.value)} + className="input w-full" + placeholder="Brief summary of the dispute" + required + minLength={5} + /> +
+ +
+ +