commit 606b7bd6b38aee0b1add301d1705f643dc62781c Author: curo1305 Date: Sun Apr 12 15:00:44 2026 +0200 Initial project scaffold: FastAPI + React/Vite + PostgreSQL SaaS starter Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..caedbad --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(git init:*)", + "Bash(git add:*)", + "Bash(git commit -m ':*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0cf565 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/destroying_sap +SECRET_KEY=change-me-in-production +CORS_ORIGINS=["http://localhost:5173"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf55016 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.pyc +.venv/ +dist/ +*.egg-info/ + +# Env +.env + +# Node +node_modules/ +frontend/dist/ + +# DB +*.sqlite + +# OS +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3dfcf0e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Stack + +| Layer | Tech | +|---|---| +| Backend | FastAPI (async), SQLAlchemy 2 (async), Alembic, PostgreSQL | +| Auth | JWT via `python-jose`, bcrypt via `passlib` | +| Frontend | React 18, TypeScript, Vite, React Router v6, TanStack Query, Axios | +| Dev DB | PostgreSQL 16 via Docker Compose | + +## Commands + +### Backend (run from `backend/`) + +```bash +# Install +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" + +# Run dev server +uvicorn app.main:app --reload + +# Lint / format +ruff check . && ruff format . + +# Tests +pytest +pytest tests/test_auth.py # single file + +# Migrations +alembic revision --autogenerate -m "describe change" +alembic upgrade head +alembic downgrade -1 +``` + +### Frontend (run from `frontend/`) + +```bash +npm install +npm run dev # Vite dev server at :5173, proxies /api → :8000 +npm run build +npm run typecheck +npm run lint +``` + +### Full stack via Docker + +```bash +cp .env.example backend/.env +docker compose up --build +``` + +## Architecture + +### Request flow + +``` +Browser → Vite dev server (:5173) + /api/* → proxy → FastAPI (:8000) + → router → dependency injection (get_db, get_current_user) + → SQLAlchemy async session → PostgreSQL +``` + +### Backend layout + +- `app/main.py` — FastAPI app, CORS, router registration +- `app/core/config.py` — all settings via `pydantic-settings` (reads `.env`) +- `app/core/security.py` — password hashing and JWT encode/decode +- `app/database.py` — async engine, `AsyncSessionLocal`, `Base` (all models inherit from here) +- `app/models/` — SQLAlchemy ORM models; import them all in `__init__.py` so Alembic detects them +- `app/schemas/` — Pydantic request/response models (separate from ORM models) +- `app/routers/` — one file per resource; mount in `main.py` +- `app/deps.py` — FastAPI dependencies: `get_current_user` validates JWT and returns `User` + +### Frontend layout + +- `src/api/client.ts` — single Axios instance; all API calls live here, token injected via interceptor +- `src/hooks/useAuth.ts` — token state (localStorage), `login`, `logout`; consumed by pages and `App.tsx` +- `src/pages/` — one file per route; data fetching via TanStack Query +- `src/App.tsx` — route tree; `PrivateRoute` wrapper redirects to `/login` when no token + +### Auth flow + +1. `POST /api/auth/login` returns a JWT bearer token +2. Token stored in `localStorage`, attached to every request by the Axios interceptor +3. Protected routes call `GET /api/users/me`; `get_current_user` dep validates the token on the server + +### Adding a new resource + +1. Add ORM model in `app/models/`, import it in `app/models/__init__.py` +2. Run `alembic revision --autogenerate -m "add "` + `alembic upgrade head` +3. Add Pydantic schemas in `app/schemas/` +4. Add router in `app/routers/`, mount it in `app/main.py` +5. Add API function(s) to `src/api/client.ts`, add page/component, register route in `App.tsx` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..420ed61 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+asyncpg://postgres:password@localhost:5432/destroying_sap + +[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..42a6171 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,47 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import settings +from app.database import Base +import app.models # noqa: F401 — ensure all models are registered + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.DATABASE_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(): + engine = create_async_engine(settings.DATABASE_URL) + async with engine.connect() as conn: + await conn.run_sync(do_run_migrations) + await engine.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..17dcba0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +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"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..7ecd576 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,19 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + PROJECT_NAME: str = "destroying_sap" + + DATABASE_URL: str = "postgresql+asyncpg://postgres:password@localhost:5432/destroying_sap" + + SECRET_KEY: str = "change-me-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 1 day + + CORS_ORIGINS: list[str] = ["http://localhost:5173"] + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..3480840 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(subject: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return jwt.encode( + {"sub": subject, "exp": expire}, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM, + ) + + +def decode_access_token(token: str) -> str: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload["sub"] diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d9b3829 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..49b4f1d --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,32 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_access_token +from app.database import get_db +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + user_id = decode_access_token(token) + except JWTError: + raise credentials_exception + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user or not user.is_active: + raise credentials_exception + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f798a62 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.routers import auth, users + +app = FastAPI(title=settings.PROJECT_NAME, version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(users.router, prefix="/api/users", tags=["users"]) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..b2e47e8 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.user import User + +__all__ = ["User"] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..eb32585 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,17 @@ +import uuid + +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String, nullable=False) + full_name: Mapped[str] = mapped_column(String, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..3cf346e --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import create_access_token, hash_password, verify_password +from app.database import get_db +from app.models.user import User +from app.schemas.user import Token, UserCreate, UserOut + +router = APIRouter() + + +@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) +async def register(body: UserCreate, db: AsyncSession = Depends(get_db)): + existing = await db.execute(select(User).where(User.email == body.email)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + user = User( + email=body.email, + hashed_password=hash_password(body.password), + full_name=body.full_name, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.post("/login", response_model=Token) +async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == form.username)) + user = result.scalar_one_or_none() + if not user or not verify_password(form.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Incorrect email or password") + + return Token(access_token=create_access_token(user.id)) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..ebb0274 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends + +from app.deps import get_current_user +from app.models.user import User +from app.schemas.user import UserOut + +router = APIRouter() + + +@router.get("/me", response_model=UserOut) +async def get_me(current_user: User = Depends(get_current_user)): + return current_user diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..f5c695d --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + password: str + full_name: str | None = None + + +class UserOut(BaseModel): + id: str + email: str + full_name: str | None + is_active: bool + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..ef80b99 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "destroying_sap" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.29", + "alembic>=1.13", + "pydantic[email]>=2.7", + "pydantic-settings>=2.2", + "python-jose[cryptography]>=3.3", + "passlib[bcrypt]>=1.7", + "python-multipart>=0.0.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "httpx>=0.27", + "ruff>=0.4", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..981e1c7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: destroying_sap + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + backend: + build: ./backend + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - ./backend:/app + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://postgres:password@db:5432/destroying_sap + depends_on: + - db + + frontend: + build: ./frontend + command: npm run dev -- --host + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:5173" + depends_on: + - backend + +volumes: + postgres_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..38bbcfb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + destroying_sap + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..59df6ed --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "destroying-sap-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "@tanstack/react-query": "^5.40.0", + "axios": "^1.7.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "typescript": "^5.4.5", + "vite": "^5.3.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..49a9840 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,27 @@ +import { Routes, Route, Navigate } from "react-router-dom"; +import LoginPage from "./pages/LoginPage"; +import RegisterPage from "./pages/RegisterPage"; +import DashboardPage from "./pages/DashboardPage"; +import { useAuth } from "./hooks/useAuth"; + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { token } = useAuth(); + return token ? <>{children} : ; +} + +export default function App() { + return ( + + } /> + } /> + + + + } + /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..0f642e9 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,23 @@ +import axios from "axios"; + +const api = axios.create({ baseURL: "/api" }); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem("token"); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +export default api; + +// --- Auth --- +export const login = (email: string, password: string) => + api + .post<{ access_token: string }>("/auth/login", new URLSearchParams({ username: email, password })) + .then((r) => r.data.access_token); + +export const register = (email: string, password: string, full_name?: string) => + api.post("/auth/register", { email, password, full_name }).then((r) => r.data); + +// --- Users --- +export const getMe = () => api.get("/users/me").then((r) => r.data); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..07d36f2 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { login as apiLogin } from "../api/client"; + +export function useAuth() { + const [token, setToken] = useState(() => localStorage.getItem("token")); + const navigate = useNavigate(); + + const login = async (email: string, password: string) => { + const t = await apiLogin(email, password); + localStorage.setItem("token", t); + setToken(t); + navigate("/"); + }; + + const logout = () => { + localStorage.removeItem("token"); + setToken(null); + navigate("/login"); + }; + + return { token, login, logout }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..b5a4709 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import App from "./App"; + +const queryClient = new QueryClient(); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..a9f51ba --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { getMe } from "../api/client"; +import { useAuth } from "../hooks/useAuth"; + +export default function DashboardPage() { + const { logout } = useAuth(); + const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe }); + + return ( +
+

Dashboard

+ {user &&

Welcome, {user.full_name ?? user.email}

} + +
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..ff1c2cd --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export default function LoginPage() { + const { login } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + try { + await login(email, password); + } catch { + setError("Invalid email or password."); + } + }; + + return ( +
+

Sign in

+
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+

+ No account? Register +

+
+ ); +} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..812ef60 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { register } from "../api/client"; + +export default function RegisterPage() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [fullName, setFullName] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + try { + await register(email, password, fullName); + navigate("/login"); + } catch { + setError("Registration failed. Email may already be in use."); + } + }; + + return ( +
+

Create account

+
+
+ + setFullName(e.target.value)} /> +
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+

+ Already have an account? Sign in +

+
+ ); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..fc84e35 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..857205d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + }, + }, + }, +});