- 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>
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
/loginor/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:
- Add
userstable - Add
user_idcolumn (nullable) to:charts,annotation_types,span_label_types,annotations,span_annotations - Create a default admin user
- UPDATE all existing rows to set
user_id = admin_user_id - ALTER columns to NOT NULL
- Add foreign key constraints
- Drop old unique constraints, add new composite unique constraints (e.g.,
(user_id, name)for annotation_types) - Add indexes on
user_idcolumns for query performance
Deployment steps:
- Update
.envwith new variables:AUTH_SECRET,AUTH_GOOGLE_ID,AUTH_GOOGLE_SECRET - Run database migrations
- Run data migration script (assign existing data to admin user)
- Deploy new app version
- First login as admin user to verify data
Rollback strategy:
- Keep
user_idnullable during testing phase - If rollback needed: revert code,
user_idcolumns remain but are ignored - No data loss on rollback
Open Questions
- 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. - Google OAuth project: Do we have a Google Cloud project with OAuth credentials ready, or should we document setup steps?
- ML model isolation: Should trained models be fully isolated per user (separate MLflow experiments), or is a shared model registry acceptable initially?