candle-annotator/openspec/changes/user-accounts/design.md
Marko Djordjevic c36ab7c146 Implement task 6.1: Create PUT /api/auth/profile endpoint for updating user display name
- Create src/app/api/auth/profile/route.ts with PUT handler
- Validates user is authenticated (returns 401 if not)
- Validates request body has a non-empty name field
- Updates user's name in the database
- Returns 200 with updated user data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 10:20:20 +01:00

10 KiB

Context

CandleAnnotator is a single-user full-stack app: Next.js 16 (App Router) + PostgreSQL (Drizzle ORM) + Python FastAPI ML service. All data is global — no users table, no auth, no data isolation. The entire app lives at / as a single page with 13 API routes.

We need to add multi-user support: registration, login, per-user data isolation. Lovable design mockups exist for landing, login, and register pages. The existing app workspace moves behind auth.

Current stack: Next.js 16, React 19, Drizzle ORM, PostgreSQL 16, Tailwind CSS, shadcn/ui, lightweight-charts, FastAPI (Python).

Goals / Non-Goals

Goals:

  • Email/password registration and login
  • Google OAuth login
  • Per-user data isolation (charts, annotations, models, settings)
  • Landing page, login page, register page (from Lovable designs)
  • User settings page (change password, display name, delete account)
  • Auth middleware protecting app routes and API routes
  • Smooth migration path for existing single-user data

Non-Goals:

  • Email verification (defer — add later)
  • Password reset via email (defer — requires email service)
  • Admin panel / user management
  • Role-based access control
  • Rate limiting
  • Two-factor authentication
  • Social login beyond Google (GitHub, Discord, etc.)
  • Team/organization accounts
  • API key authentication for external integrations

Decisions

1. Auth library: Auth.js v5 (NextAuth v5)

Choice: Auth.js v5 (next-auth@5) with JWT strategy

Why: Native App Router support, built-in Credentials + Google OAuth providers, @auth/drizzle-adapter exists for our ORM. JWT strategy avoids extra session table and keeps the ML service integration simple (pass user ID in headers).

Alternatives considered:

  • Lucia Auth: Good but deprecated in favor of rolling your own. More manual setup.
  • Clerk/Auth0: SaaS dependency, cost, overkill for this project.
  • Custom JWT: More control but reinventing auth middleware, CSRF protection, etc.

Session strategy: JWT (not database sessions). User ID embedded in token. Stateless, no session table needed.

2. Password hashing: bcryptjs

Choice: bcryptjs (pure JS bcrypt implementation)

Why: No native dependencies (works in Docker without build tools), well-tested, sufficient for this use case.

Alternatives considered:

  • bcrypt (native): Requires build tools in Docker, fails on some platforms.
  • argon2: Better algorithm but requires native bindings. Overhead not justified here.

3. Database schema: users table + user_id FK on all tables

Choice: Add users table. Add user_id column (nullable initially for migration, then NOT NULL) to: charts, annotations, annotation_types, span_annotations, span_label_types.

Users table:

users
├── id: uuid (PK, default gen_random_uuid())
├── name: text (nullable)
├── email: text (unique, not null)
├── email_verified: timestamp (nullable)
├── password_hash: text (nullable — null for OAuth-only users)
├── image: text (nullable)
├── provider: text (default 'credentials') — 'credentials' | 'google'
├── provider_account_id: text (nullable) — Google sub ID
├── created_at: timestamp
├── updated_at: timestamp

Why UUID for user IDs: Avoids enumeration attacks, safe to expose in URLs/tokens, standard for auth systems.

Why NOT use Drizzle adapter's default schema: The @auth/drizzle-adapter creates users, accounts, sessions, verification_tokens tables. We'll use a simplified custom schema since we're using JWT (no sessions table) and deferring email verification. We'll handle the adapter mapping manually.

Alternatives considered:

  • Drizzle adapter default schema: Creates 4 tables, adds complexity for features we don't need yet (verification tokens, separate accounts table). We can adopt it later if we add email verification or more OAuth providers.
  • Serial integer IDs: Enumerable, less secure for user-facing IDs.

4. Routing structure

Choice:

/           → Landing page (public)
/login      → Login page (public)
/register   → Register page (public)
/app        → Main workspace (protected, current page.tsx moves here)
/app/settings → User settings (protected)
/api/auth/* → NextAuth API routes
/api/*      → Existing API routes (protected)

Why: Clean separation of public marketing pages and protected app. /app prefix makes middleware rules simple. Existing API routes stay at /api/* — just add auth check.

Alternatives considered:

  • Keep app at / and use /landing: Unconventional, confuses SEO.
  • Subdomain (app.candleannotator.com): Adds deployment complexity.

5. Auth middleware approach

Choice: Next.js proxy.ts at project root. Uses Auth.js auth() to check session. Redirects:

  • Unauthenticated users hitting /app/* or /api/* (except /api/auth/* and /api/health) → redirect to /login
  • Authenticated users hitting /login or /register → redirect to /app

Why: Single enforcement point, runs on edge before page/API renders. Clean and standard Next.js pattern.

6. API route auth pattern

Choice: Helper function getAuthUser(request) that extracts user from JWT session. Every API route calls this at the top:

const user = await getAuthUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// All queries filter by user.id

All Drizzle queries add .where(eq(table.user_id, user.id)) for reads, and set user_id: user.id on inserts.

Why: Simple, explicit, no magic. Every route clearly shows it's user-scoped.

7. ML Service user context

Choice: Pass X-User-ID header from Next.js API routes to FastAPI ML service. The ML service trusts this header (internal network only, not exposed to clients).

Why: ML service doesn't need its own auth — it's internal. The Next.js layer validates auth and forwards user context. MLflow experiment names include user ID for isolation.

Alternatives considered:

  • JWT forwarding: Requires Python JWT validation, adds complexity.
  • Separate user DB in Python: Duplicates user management.

8. User data seeding on registration

Choice: On successful registration, seed default annotation_types and span_label_types for the new user (copy from a hardcoded default set).

Why: New users need at least the standard annotation types (break_up, break_down, line) and span label types to start working. Without seeding, the app would be empty/broken.

9. Frontend auth state

Choice: Use next-auth/react SessionProvider wrapping the app layout. Components use useSession() hook. Server components use auth() from @/auth.

Why: Standard Auth.js pattern. SessionProvider handles token refresh automatically.

10. Lovable design integration

Choice: Convert Lovable HTML mockups to React/Next.js components using existing Tailwind + shadcn/ui patterns. Extract the structure and classes, adapt to Next.js <Link>, form actions, etc. Keep the visual design but rewrite as proper React.

Why: Lovable generates static HTML with Tailwind. Direct reuse of CSS classes is possible, but the HTML structure needs to become React components with proper event handling.

11. Settings page features

Choice: /app/settings page with sections:

  • Profile: Display name, email (read-only for OAuth users)
  • Security: Change password (credentials users only)
  • Danger zone: Delete account (with confirmation dialog)

Why: Covers essential user management needs. Change password is table stakes. Delete account is GDPR-friendly. Keep it minimal — add more settings later as needed.

Risks / Trade-offs

[Data migration for existing deployments] → Create a migration script that assigns all existing data to a default "admin" user. Document in DEPLOYMENT.md. The migration adds user_id as nullable first, runs the script, then makes it NOT NULL.

[OAuth users can't set password] → If a user registers via Google, they have no password. The settings page should show "Signed in via Google" instead of password change form. If they want password login later, they'd need a "set password" flow (defer to future).

[JWT secret rotation] → If AUTH_SECRET is changed, all existing sessions invalidate. Document this. Not a risk per se, but users will be logged out.

[ML service trusts X-User-ID header] → The ML service is only accessible internally (bound to 127.0.0.1 in docker-compose). If it were exposed publicly, this would be a security hole. Document that ML service must never be publicly accessible.

[No email verification initially] → Users can register with any email. This means no password reset via email. Acceptable for initial release — add email verification later.

[Unique constraint on annotation_types.name becomes per-user] → Currently annotation_types.name has a unique constraint. With multi-user, the same name can exist for different users. Change to unique constraint on (user_id, name). Same for span_label_types.name.

Migration Plan

Database migration steps:

  1. Add users table
  2. Add user_id column (nullable) to: charts, annotation_types, span_label_types, annotations, span_annotations
  3. Create a default admin user
  4. UPDATE all existing rows to set user_id = admin_user_id
  5. ALTER columns to NOT NULL
  6. Add foreign key constraints
  7. Drop old unique constraints, add new composite unique constraints (e.g., (user_id, name) for annotation_types)
  8. Add indexes on user_id columns for query performance

Deployment steps:

  1. Update .env with new variables: AUTH_SECRET, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
  2. Run database migrations
  3. Run data migration script (assign existing data to admin user)
  4. Deploy new app version
  5. First login as admin user to verify data

Rollback strategy:

  • Keep user_id nullable during testing phase
  • If rollback needed: revert code, user_id columns remain but are ignored
  • No data loss on rollback

Open Questions

  1. Default admin credentials: What email/password should the default admin user have for migrating existing data? Suggest: configurable via env var DEFAULT_ADMIN_EMAIL / DEFAULT_ADMIN_PASSWORD.
  2. Google OAuth project: Do we have a Google Cloud project with OAuth credentials ready, or should we document setup steps?
  3. ML model isolation: Should trained models be fully isolated per user (separate MLflow experiments), or is a shared model registry acceptable initially?