diff --git a/.env.example b/.env.example index e55057c..765b23a 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,9 @@ GEMINI_MODEL=gemini-2.5-flash OPENAI_API_KEY='' OPENAI_MODEL=gpt-4.1-mini LOG_LEVEL=INFO +# Optional: path to SQLite DB file (default: planera.db next to requirements.txt) +# DATABASE_PATH=/absolute/path/to/planera.db +# Required for real deployments: strong random secret for signing JWTs +# JWT_SECRET_KEY=change-me +# JWT_ALGORITHM=HS256 +# ACCESS_TOKEN_EXPIRE_MINUTES=10080 diff --git a/.gitignore b/.gitignore index aaaa218..0f0c76f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ coverage/ .venv/ venv/ __pycache__/ +planera.db *.py[cod] .pytest_cache/ .mypy_cache/ diff --git a/README.md b/README.md index a851fa8..601c309 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,18 @@ pip install -r requirements.txt cp .env.example .env ``` +Use this project virtualenv for all Python commands (`uvicorn`, `pytest`, `pip`). In each new shell, activate it first: + +```bash +source .venv/bin/activate +``` + +*(Windows Git Bash: `source .venv/Scripts/activate` — PowerShell: `.venv\Scripts\Activate.ps1`.)* + ### 2. Run the API ```bash +source .venv/bin/activate uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` @@ -113,6 +122,11 @@ API endpoints: - `POST /uploads` - `GET /inspections/{inspection_id}` - `POST /analyze` +- `POST /auth/signup` — create user (SQLite), returns JWT +- `POST /auth/login` — issue JWT +- `GET /auth/me` — current user (`Authorization: Bearer `) + +**Database:** On API startup the app creates SQLite tables if needed (no separate migration step for this demo). By default the DB file is `planera.db` in the project root (same directory as `requirements.txt`). Override with `DATABASE_PATH` in `.env`. Add a strong `JWT_SECRET_KEY` before any shared deployment; the repo default is for local dev only. Example request: @@ -146,6 +160,10 @@ Backend settings are defined in `.env.example`: - `OPENAI_API_KEY` - `OPENAI_MODEL` - `LOG_LEVEL` +- `DATABASE_PATH` (optional; default `planera.db` beside `requirements.txt`) +- `JWT_SECRET_KEY` (optional for local dev; **required** for non-local use) +- `JWT_ALGORITHM` (default `HS256`) +- `ACCESS_TOKEN_EXPIRE_MINUTES` (default `10080`) Frontend settings live in `ui/.env.example`. @@ -181,8 +199,12 @@ Key derived fields include: ## Running Tests +Use the project virtualenv so `pytest` and packages like `passlib` match `requirements.txt` (if you use Conda/base Python, a bare `pytest` may run the wrong interpreter and fail imports): + ```bash -pytest +source .venv/bin/activate +pip install -r requirements.txt +python -m pytest ``` The test suite covers: diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py new file mode 100644 index 0000000..478c582 --- /dev/null +++ b/app/api/auth_routes.py @@ -0,0 +1,64 @@ +"""Authentication routes (JWT access tokens, SQLite-backed users).""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.auth.schemas import AuthTokenResponse, LoginRequest, MeResponse, SignupRequest, UserPublic +from app.auth.security import create_access_token, hash_password, normalize_email, verify_password +from app.db.session import get_db +from app.models.user import User + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _display_name_from_signup(display_name: str | None) -> str | None: + if display_name is None: + return None + stripped = display_name.strip() + return stripped or None + + +@router.post("/signup", response_model=AuthTokenResponse) +def signup(body: SignupRequest, db: Session = Depends(get_db)) -> AuthTokenResponse: + email = normalize_email(str(body.email)) + existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": "Email already registered."}, + ) + + user = User( + email=email, + hashed_password=hash_password(body.password), + display_name=_display_name_from_signup(body.display_name), + ) + db.add(user) + db.commit() + db.refresh(user) + token = create_access_token(subject_user_id=user.id) + return AuthTokenResponse(user=UserPublic.model_validate(user), access_token=token) + + +@router.post("/login", response_model=AuthTokenResponse) +def login(body: LoginRequest, db: Session = Depends(get_db)) -> AuthTokenResponse: + email = normalize_email(str(body.email)) + user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if user is None or not verify_password(body.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "Invalid email or password."}, + ) + + token = create_access_token(subject_user_id=user.id) + return AuthTokenResponse(user=UserPublic.model_validate(user), access_token=token) + + +@router.get("/me", response_model=MeResponse) +def me(current_user: User = Depends(get_current_user)) -> MeResponse: + return MeResponse(user=UserPublic.model_validate(current_user)) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..fec52ea --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication helpers and dependencies.""" diff --git a/app/auth/deps.py b/app/auth/deps.py new file mode 100644 index 0000000..a884052 --- /dev/null +++ b/app/auth/deps.py @@ -0,0 +1,46 @@ +"""FastAPI dependencies for authentication.""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.auth.security import decode_access_token +from app.db.session import get_db +from app.models.user import User + +_bearer = HTTPBearer(auto_error=False) + + +def get_current_user( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), + db: Session = Depends(get_db), +) -> User: + if creds is None or creds.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "Not authenticated."}, + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_access_token(creds.credentials) + user_id_str: str | None = payload.get("sub") + if user_id_str is None: + raise ValueError("missing sub") + user_id = int(user_id_str) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "Invalid or expired token."}, + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + user = db.get(User, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "User no longer exists."}, + headers={"WWW-Authenticate": "Bearer"}, + ) + return user diff --git a/app/auth/schemas.py b/app/auth/schemas.py new file mode 100644 index 0000000..0e040ed --- /dev/null +++ b/app/auth/schemas.py @@ -0,0 +1,37 @@ +"""Pydantic schemas for auth API payloads.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + + +class SignupRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8, max_length=128) + display_name: str | None = Field(default=None, max_length=255) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=1, max_length=128) + + +class UserPublic(BaseModel): + model_config = {"from_attributes": True} + + id: int + email: str + display_name: str | None + created_at: datetime + + +class AuthTokenResponse(BaseModel): + user: UserPublic + access_token: str + token_type: str = "bearer" + + +class MeResponse(BaseModel): + user: UserPublic diff --git a/app/auth/security.py b/app/auth/security.py new file mode 100644 index 0000000..28e1f45 --- /dev/null +++ b/app/auth/security.py @@ -0,0 +1,36 @@ +"""Password hashing and JWT helpers.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import jwt +from passlib.context import CryptContext + +from app.config import get_settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def normalize_email(email: str) -> str: + return email.strip().lower() + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(*, subject_user_id: int) -> str: + settings = get_settings() + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = {"sub": str(subject_user_id), "exp": expire} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict: + settings = get_settings() + return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) diff --git a/app/config.py b/app/config.py index 1e3ad13..7c9d9d8 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,14 @@ class Settings(BaseSettings): default_factory=lambda: ["http://localhost:5173", "http://127.0.0.1:5173"], alias="CORS_ALLOW_ORIGINS", ) + # Auth / SQLite (set JWT_SECRET_KEY in any shared or production-like environment) + database_path: Path = Field(default=BASE_DIR / "planera.db", alias="DATABASE_PATH") + jwt_secret_key: str = Field( + default="dev-insecure-jwt-secret-change-me", + alias="JWT_SECRET_KEY", + ) + jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM") + access_token_expire_minutes: int = Field(default=10080, alias="ACCESS_TOKEN_EXPIRE_MINUTES") # 7 days @field_validator("cors_allow_origins", mode="before") @classmethod diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..4d25645 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1,6 @@ +"""Database package.""" + +from app.db.base import Base +from app.db.session import get_db, get_engine, reset_engine_and_session + +__all__ = ["Base", "get_db", "get_engine", "reset_engine_and_session"] diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..2d70ff7 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,9 @@ +"""Declarative base for SQLAlchemy models.""" + +from __future__ import annotations + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Application ORM base class.""" diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..3df514e --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,51 @@ +"""SQLite engine and session factory (lazy init so tests can override settings first).""" + +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import get_settings + +_engine = None +_session_factory: sessionmaker[Session] | None = None + + +def reset_engine_and_session() -> None: + """Dispose the engine and clear factories (used by tests after changing DB path).""" + + global _engine, _session_factory + if _engine is not None: + _engine.dispose() + _engine = None + _session_factory = None + + +def get_engine(): + global _engine + if _engine is None: + settings = get_settings() + _engine = create_engine( + f"sqlite:///{settings.database_path}", + connect_args={"check_same_thread": False}, + ) + return _engine + + +def get_session_factory() -> sessionmaker[Session]: + global _session_factory + if _session_factory is None: + _session_factory = sessionmaker(autocommit=False, autoflush=False, bind=get_engine()) + return _session_factory + + +def get_db() -> Generator[Session, None, None]: + """FastAPI dependency: one request-scoped session.""" + + db = get_session_factory()() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py index e184e65..058ff99 100644 --- a/app/main.py +++ b/app/main.py @@ -2,9 +2,12 @@ from __future__ import annotations +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.auth_routes import router as auth_router from app.api.routes import router from app.config import get_settings from app.utils.logging import configure_logging @@ -13,10 +16,24 @@ settings = get_settings() configure_logging(settings.log_level) + +@asynccontextmanager +async def lifespan(_app: FastAPI): + """Create database tables on startup (SQLite file demo; no Alembic in this phase).""" + + from app.db.base import Base + from app.db.session import get_engine + from app.models import User # noqa: F401 + + Base.metadata.create_all(bind=get_engine()) + yield + + app = FastAPI( title=settings.app_name, version="0.1.0", description="Natural-language analytics copilot for GTM teams.", + lifespan=lifespan, ) app.add_middleware( CORSMiddleware, @@ -25,4 +42,5 @@ allow_methods=["*"], allow_headers=["*"], ) +app.include_router(auth_router) app.include_router(router) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..788cc58 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +"""ORM models (import for metadata registration).""" + +from app.models.user import User + +__all__ = ["User"] diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..1c02a84 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,24 @@ +"""User ORM model.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utc_now, nullable=False) diff --git a/requirements.txt b/requirements.txt index c26faee..7ddcedd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,9 @@ httpx==0.28.1 nodeenv==1.9.1 python-multipart==0.0.20 Jinja2==3.1.6 +SQLAlchemy==2.0.41 +passlib[bcrypt]==1.7.4 +# bcrypt 4.1+ dropped __about__.__version__; passlib 1.7.4 still reads it (harmless warning or noisy logs). +bcrypt>=4.0.1,<4.1 +PyJWT==2.10.1 +email-validator==2.2.0 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..06786f7 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,73 @@ +"""Tests for JWT auth endpoints (SQLite isolated per test module via temp DB path).""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.db.session import reset_engine_and_session +from app.main import app + + +@pytest.fixture +def auth_client(tmp_path, monkeypatch): + """Use a fresh SQLite file so auth tests do not touch the default planera.db.""" + + monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "auth_test.sqlite")) + get_settings.cache_clear() + reset_engine_and_session() + with TestClient(app) as client: + yield client + get_settings.cache_clear() + reset_engine_and_session() + + +def test_signup_login_me_flow(auth_client: TestClient) -> None: + signup = auth_client.post( + "/auth/signup", + json={ + "email": "Person@Example.COM", + "password": "correcthorse", + "display_name": " Demo ", + }, + ) + assert signup.status_code == 200 + body = signup.json() + assert body["token_type"] == "bearer" + assert body["user"]["email"] == "person@example.com" + assert body["user"]["display_name"] == "Demo" + assert "access_token" in body + token = body["access_token"] + + me = auth_client.get("/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 200 + assert me.json()["user"]["email"] == "person@example.com" + + login = auth_client.post( + "/auth/login", + json={"email": "person@example.com", "password": "correcthorse"}, + ) + assert login.status_code == 200 + assert login.json()["user"]["id"] == body["user"]["id"] + + +def test_signup_duplicate_email_conflict(auth_client: TestClient) -> None: + payload = {"email": "dup@example.com", "password": "password1"} + assert auth_client.post("/auth/signup", json=payload).status_code == 200 + dup = auth_client.post("/auth/signup", json=payload) + assert dup.status_code == 409 + assert dup.json()["detail"]["message"] == "Email already registered." + + +def test_me_requires_bearer_token(auth_client: TestClient) -> None: + r = auth_client.get("/auth/me") + assert r.status_code == 401 + assert r.json()["detail"]["message"] == "Not authenticated." + + +def test_login_invalid_credentials(auth_client: TestClient) -> None: + auth_client.post("/auth/signup", json={"email": "u@example.com", "password": "password1"}) + bad = auth_client.post("/auth/login", json={"email": "u@example.com", "password": "wrong"}) + assert bad.status_code == 401 + assert bad.json()["detail"]["message"] == "Invalid email or password." diff --git a/ui/README.md b/ui/README.md index b54ef91..34df97d 100644 --- a/ui/README.md +++ b/ui/README.md @@ -4,11 +4,12 @@ Planera is a premium analytics copilot frontend built with React, TypeScript, Vi This app is designed to work against a separately hosted backend API and includes a dedicated service layer for: +- authentication (JWT session against `POST /auth/login`, `POST /auth/signup`, `GET /auth/me`) - chat submission - file uploads - inspection data - validation and trace metadata -- conversation history +- conversation history (still mostly local/demo until a history API exists) When the backend is unavailable, the UI can fall back to seeded demo data so the product remains demo-ready. @@ -32,11 +33,9 @@ cp .env.example .env npm run dev ``` -4. Start the backend API from this repo separately on port `8000` +4. Start the backend API from the repo root (`../` from this folder) on port `8000` (see root `README.md`: `source .venv/bin/activate` then `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000`). -The current live integration expects the FastAPI backend in [`/Users/ayushgaur/MLH_UV/planera`](/Users/ayushgaur/MLH_UV/planera) to be running at `http://localhost:8000`. - -4. Build for production +5. Build for production ```bash npm run build @@ -67,7 +66,7 @@ VITE_API_FALLBACK_MODE=hybrid Fallback modes: - `hybrid`: try the backend first, then fall back to seeded demo data -- `demo`: use demo data only +- `demo`: use demo data only for **analysis/upload** calls; **auth** (`/auth/*`) still hits the API so you can sign in while the rest of the app uses mocks - `live`: fail loudly when the backend is unavailable ## Project Structure @@ -82,6 +81,7 @@ planera-ui/ │ │ ├── marketing/ │ │ └── shared/ │ ├── config/ +│ ├── context/ │ ├── data/ │ ├── hooks/ │ ├── layouts/ @@ -106,17 +106,25 @@ planera-ui/ The frontend keeps request logic out of presentational components. Update endpoints in the service layer: -- [`src/api/client.ts`](./src/api/client.ts) +- [`src/api/client.ts`](./src/api/client.ts) — shared `request()` / `requestWithAuth()`; supports `authToken` and FastAPI validation (`422`) error text +- [`src/api/auth.ts`](./src/api/auth.ts) — login, signup, `/auth/me` - [`src/api/chat.ts`](./src/api/chat.ts) - [`src/api/uploads.ts`](./src/api/uploads.ts) - [`src/api/inspections.ts`](./src/api/inspections.ts) +### Auth session + +- [`src/context/AuthProvider.tsx`](./src/context/AuthProvider.tsx) and [`src/hooks/useAuth.ts`](./src/hooks/useAuth.ts) hold the current user and JWT; the token is stored under `planera.accessToken` in `localStorage`. +- [`src/router/ProtectedRoute.tsx`](./src/router/ProtectedRoute.tsx) guards `/app` and `/settings`; unauthenticated users go to `/sign-in` (with `state.from` for post-login redirect). +- Sign out clears storage and is available from the workspace sidebar and Settings. + Current live contract: +- `POST /auth/signup`, `POST /auth/login`, `GET /auth/me` — session and route protection - `POST /analyze` is used for real chat submissions - `POST /uploads` profiles CSV and TSV workspace uploads - `GET /inspections/:id` fetches a stored inspection payload when it is not already cached client-side -- `GET /sample-questions` can be added to the UI later for dynamic prompt suggestions +- `GET /sample-questions` can be wired for dynamic prompt suggestions Current gaps in the backend contract: diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts new file mode 100644 index 0000000..7441eba --- /dev/null +++ b/ui/src/api/auth.ts @@ -0,0 +1,26 @@ +import { request } from "@/api/client"; +import type { AuthTokenResponse, LoginRequestBody, MeResponse, SignupRequestBody } from "@/types/auth"; + +export async function loginRequest(body: LoginRequestBody) { + return request("/auth/login", { + method: "POST", + body: JSON.stringify(body), + allowInDemoOnly: true, + }); +} + +export async function signupRequest(body: SignupRequestBody) { + return request("/auth/signup", { + method: "POST", + body: JSON.stringify(body), + allowInDemoOnly: true, + }); +} + +export async function meRequest(accessToken: string) { + return request("/auth/me", { + method: "GET", + authToken: accessToken, + allowInDemoOnly: true, + }); +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index d870b2b..e56d155 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -12,6 +12,10 @@ export class ApiError extends Error { interface RequestOptions extends RequestInit { raw?: boolean; + /** When set, sends `Authorization: Bearer `. */ + authToken?: string | null; + /** Auth and health checks may still call the API while `VITE_API_FALLBACK_MODE=demo`. */ + allowInDemoOnly?: boolean; } function buildUrl(path: string) { @@ -29,6 +33,17 @@ async function buildErrorMessage(response: Response) { try { const payload = JSON.parse(text) as { detail?: unknown; message?: unknown }; if (typeof payload.detail === "string") return payload.detail; + if (Array.isArray(payload.detail)) { + const messages = payload.detail + .map((item) => { + if (item && typeof item === "object" && "msg" in item && typeof (item as { msg: unknown }).msg === "string") { + return (item as { msg: string }).msg; + } + return null; + }) + .filter(Boolean); + if (messages.length > 0) return dedupeMessages(messages as string[]).join(" "); + } if (payload.detail && typeof payload.detail === "object" && "message" in payload.detail) { const message = (payload.detail as { message?: unknown }).message; if (typeof message === "string") return message; @@ -41,13 +56,21 @@ async function buildErrorMessage(response: Response) { return text; } +function dedupeMessages(parts: string[]) { + return [...new Set(parts.map((p) => p.trim()).filter(Boolean))]; +} + export async function request(path: string, options: RequestOptions = {}): Promise { - if (isDemoOnlyMode) { + if (isDemoOnlyMode && !options.allowInDemoOnly) { throw new ApiError("Demo-only mode enabled."); } const headers = new Headers(options.headers); + if (options.authToken) { + headers.set("Authorization", `Bearer ${options.authToken}`); + } + if (!(options.body instanceof FormData) && !headers.has("Content-Type") && options.body) { headers.set("Content-Type", "application/json"); } @@ -67,3 +90,8 @@ export async function request(path: string, options: RequestOptions = {}): Pr return (await response.json()) as T; } + +/** Authenticated request helper — passes `Authorization: Bearer` for you. */ +export function requestWithAuth(path: string, accessToken: string | null, options: RequestOptions = {}): Promise { + return request(path, { ...options, authToken: accessToken }); +} diff --git a/ui/src/components/app/Sidebar.tsx b/ui/src/components/app/Sidebar.tsx index 07128ad..ed01f7d 100644 --- a/ui/src/components/app/Sidebar.tsx +++ b/ui/src/components/app/Sidebar.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import { Button } from "@/components/shared/Button"; import { Drawer } from "@/components/shared/Drawer"; import { UploadCard } from "@/components/app/UploadCard"; +import { useAuth } from "@/hooks/useAuth"; import { sidebarNavItems } from "@/lib/constants"; import { classNames } from "@/lib/classNames"; import { formatRelativeTime } from "@/lib/utils"; @@ -76,6 +77,8 @@ function SidebarContent({ onToggleCollapse, onAfterSelect, }: Omit & { onAfterSelect?: () => void }) { + const { user, logout } = useAuth(); + return (
@@ -220,6 +223,24 @@ function SidebarContent({
) : null} + {!collapsed && user ?

{user.email}

: null} + + + ({ + user: null, + token: null, + isReady: false, + }); + + useEffect(() => { + let cancelled = false; + + async function bootstrap() { + const stored = readStoredToken(); + if (!stored) { + if (!cancelled) { + setState({ user: null, token: null, isReady: true }); + } + return; + } + + try { + const me = await meRequest(stored); + if (cancelled) return; + setState({ user: me.user, token: stored, isReady: true }); + } catch { + persistToken(null); + if (!cancelled) { + setState({ user: null, token: null, isReady: true }); + } + } + } + + void bootstrap(); + return () => { + cancelled = true; + }; + }, []); + + const login = useCallback(async (email: string, password: string) => { + const trimmedEmail = email.trim().toLowerCase(); + const res = await loginRequest({ email: trimmedEmail, password }); + persistToken(res.access_token); + setState({ user: res.user, token: res.access_token, isReady: true }); + }, []); + + const signUp = useCallback(async (email: string, password: string, displayName?: string | null) => { + const trimmedEmail = email.trim().toLowerCase(); + const payload: SignupRequestBody = { + email: trimmedEmail, + password, + }; + if (displayName?.trim()) { + payload.display_name = displayName.trim(); + } + const res = await signupRequest(payload); + persistToken(res.access_token); + setState({ user: res.user, token: res.access_token, isReady: true }); + }, []); + + const logout = useCallback(() => { + persistToken(null); + setState({ user: null, token: null, isReady: true }); + navigate("/sign-in", { replace: true }); + }, [navigate]); + + const value = useMemo( + () => ({ + user, + token, + isReady, + isAuthenticated: Boolean(user && token), + login, + signUp, + logout, + }), + [user, token, isReady, login, signUp, logout], + ); + + return {children}; +} diff --git a/ui/src/context/auth-context.ts b/ui/src/context/auth-context.ts new file mode 100644 index 0000000..b95ff18 --- /dev/null +++ b/ui/src/context/auth-context.ts @@ -0,0 +1,17 @@ +import { createContext } from "react"; +import type { AuthUser } from "@/types/auth"; + +export interface AuthState { + user: AuthUser | null; + token: string | null; + isReady: boolean; +} + +export interface AuthContextValue extends AuthState { + isAuthenticated: boolean; + login: (email: string, password: string) => Promise; + signUp: (email: string, password: string, displayName?: string | null) => Promise; + logout: () => void; +} + +export const AuthContext = createContext(null); diff --git a/ui/src/hooks/useAuth.ts b/ui/src/hooks/useAuth.ts new file mode 100644 index 0000000..7de9379 --- /dev/null +++ b/ui/src/hooks/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { AuthContext } from "@/context/auth-context"; + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} diff --git a/ui/src/lib/authToken.ts b/ui/src/lib/authToken.ts new file mode 100644 index 0000000..e42bb7a --- /dev/null +++ b/ui/src/lib/authToken.ts @@ -0,0 +1,2 @@ +/** localStorage key for JWT access token (backend `access_token`). */ +export const AUTH_ACCESS_TOKEN_KEY = "planera.accessToken"; diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index 9df6f69..1a2cd7f 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -4,8 +4,11 @@ import { Button } from "@/components/shared/Button"; import { Card } from "@/components/shared/Card"; import { PageContainer } from "@/components/shared/PageContainer"; import { env } from "@/config/env"; +import { useAuth } from "@/hooks/useAuth"; export function SettingsPage() { + const { user, logout } = useAuth(); + return (
@@ -20,6 +23,19 @@ export function SettingsPage() {
+ +
+
+

Account

+

Signed in as {user?.email ?? "—"}

+ {user?.display_name ?

Display name: {user.display_name}

: null} +
+ +
+
+

Workspace details

diff --git a/ui/src/pages/SignInPage.tsx b/ui/src/pages/SignInPage.tsx index 367d8fe..8990703 100644 --- a/ui/src/pages/SignInPage.tsx +++ b/ui/src/pages/SignInPage.tsx @@ -1,9 +1,13 @@ import type { FormEvent } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { ApiError } from "@/api/client"; import { Button } from "@/components/shared/Button"; import { Card } from "@/components/shared/Card"; import { Input } from "@/components/shared/Input"; import { PageContainer } from "@/components/shared/PageContainer"; +import { Spinner } from "@/components/shared/Spinner"; +import { useAuth } from "@/hooks/useAuth"; import { MarketingLayout } from "@/layouts/MarketingLayout"; const signInHighlights = [ @@ -21,14 +25,67 @@ const signInHighlights = [ }, ]; +type AuthMode = "signin" | "signup"; + export function SignInPage() { const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as { from?: string } | null)?.from ?? "/app"; + const { isReady, isAuthenticated, login, signUp } = useAuth(); + + const [mode, setMode] = useState("signin"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isReady && isAuthenticated) { + navigate(from, { replace: true }); + } + }, [from, isAuthenticated, isReady, navigate]); + + const switchMode = (next: AuthMode) => { + setMode(next); + setError(null); + }; - const handleSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - navigate("/app"); + if (!isReady || submitting) { + return; + } + setError(null); + setSubmitting(true); + try { + if (mode === "signin") { + await login(email, password); + } else { + await signUp(email, password, displayName || null); + } + navigate(from, { replace: true }); + } catch (err) { + const message = err instanceof ApiError ? err.message : "Something went wrong. Try again."; + setError(message); + } finally { + setSubmitting(false); + } }; + if (!isReady) { + return ( + +
+ + +

Restoring session…

+
+
+
+ ); + } + return (
@@ -53,67 +110,120 @@ export function SignInPage() {
-

Prototype Note

+

Demo auth

- This sign-in flow currently opens the workspace preview after submission, so the page is ready for a real auth integration later without changing the navigation. + Email and password are stored in the local API (SQLite). Your session stays signed in across refreshes until you sign out.

-
+
-

Welcome back

-

Sign in to Planera

+

{mode === "signin" ? "Welcome back" : "Create an account"}

+

{mode === "signin" ? "Sign in to Planera" : "Sign up for Planera"}

Team access
+
+ + +
+
- -
- Or use email + Email {mode === "signin" ? "sign in" : "registration"}
+ {error ? ( +
+ {error} +
+ ) : null} +
+ {mode === "signup" ? ( + + ) : null} + -
- - - Need an invite? - -
- -
@@ -121,9 +231,6 @@ export function SignInPage() { Back to home - - Skip to workspace -
diff --git a/ui/src/router/ProtectedRoute.tsx b/ui/src/router/ProtectedRoute.tsx new file mode 100644 index 0000000..b25b1d7 --- /dev/null +++ b/ui/src/router/ProtectedRoute.tsx @@ -0,0 +1,24 @@ +import type { PropsWithChildren } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { Spinner } from "@/components/shared/Spinner"; +import { useAuth } from "@/hooks/useAuth"; + +export function ProtectedRoute({ children }: PropsWithChildren) { + const { isReady, isAuthenticated } = useAuth(); + const location = useLocation(); + + if (!isReady) { + return ( +
+ +

Loading session…

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/ui/src/router/RootLayout.tsx b/ui/src/router/RootLayout.tsx new file mode 100644 index 0000000..32be3ef --- /dev/null +++ b/ui/src/router/RootLayout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "react-router-dom"; +import { AuthProvider } from "@/context/AuthProvider"; + +export function RootLayout() { + return ( + + + + ); +} diff --git a/ui/src/router/index.tsx b/ui/src/router/index.tsx index f148b35..4abbd39 100644 --- a/ui/src/router/index.tsx +++ b/ui/src/router/index.tsx @@ -4,26 +4,41 @@ import { HomePage } from "@/pages/HomePage"; import { NotFoundPage } from "@/pages/NotFoundPage"; import { SettingsPage } from "@/pages/SettingsPage"; import { SignInPage } from "@/pages/SignInPage"; +import { ProtectedRoute } from "@/router/ProtectedRoute"; +import { RootLayout } from "@/router/RootLayout"; export const router = createBrowserRouter([ { - path: "/", - element: , - }, - { - path: "/app", - element: , - }, - { - path: "/settings", - element: , - }, - { - path: "/sign-in", - element: , - }, - { - path: "*", - element: , + element: , + children: [ + { + path: "/", + element: , + }, + { + path: "/sign-in", + element: , + }, + { + path: "/app", + element: ( + + + + ), + }, + { + path: "/settings", + element: ( + + + + ), + }, + { + path: "*", + element: , + }, + ], }, ]); diff --git a/ui/src/types/auth.ts b/ui/src/types/auth.ts new file mode 100644 index 0000000..6cd120b --- /dev/null +++ b/ui/src/types/auth.ts @@ -0,0 +1,27 @@ +export interface AuthUser { + id: number; + email: string; + display_name: string | null; + created_at: string; +} + +export interface AuthTokenResponse { + user: AuthUser; + access_token: string; + token_type: string; +} + +export interface MeResponse { + user: AuthUser; +} + +export interface LoginRequestBody { + email: string; + password: string; +} + +export interface SignupRequestBody { + email: string; + password: string; + display_name?: string | null; +}