From f98a0c2c025edeeeead9d2925a0f120755823cc4 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Thu, 16 Apr 2026 21:10:38 -0300 Subject: [PATCH] feat: migrate authentication to Better Auth and update profile management logic --- AGENTS.md | 26 ++++++++++++++----- .../src/features/profile/profile-page.tsx | 23 ++++++++++------ apps/frontend/src/lib/auth.tsx | 8 +++--- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ba5f00..cef168f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,18 +33,31 @@ packages/api-contract/ # Shared Zod schemas, types, route definitions 2. Implement handler in `apps/backend` 3. Consume from `apps/frontend` via workspace import `@repo/api-contract` +## Authentication (Better Auth) + +The project uses **Better Auth** for session management, replacing the legacy Supabase Auth. + +- **Backend Context**: `AppContext` (mapped via `AppEnv`) provides `c.get('user')` and `c.get('session')`. +- **User Roles**: The `User` model includes a `role` field (default: `member`). Use `requireSuperAdmin` middleware for protected admin routes. +- **Session Persistence**: + - Backend: Hono CORS must have `credentials: true`. + - Frontend: `authClient` must have `fetchOptions.credentials: 'include'`. + - Frontend: Axios instance must have `withCredentials: true`. + ## Key Quirks -- **Prisma 7**: Uses `prisma.config.ts`, requires `DATABASE_URL` env var at generate time -- **Zod v4**: Use `.issues` instead of `.errors` for validation errors -- **Generated files**: `routeTree.gen.ts` and `tsconfig*.json` are auto-generated (ignored by Biome) +- **Prisma 7**: Uses `prisma.config.ts`, requires `DATABASE_URL` env var at generate time. +- **Gravatar Fallback**: Users without an `image` get an automatic Gravatar Identicon. + - Backend: Handled in `user.service.ts` for profile API. + - Frontend: Handled in `AuthProvider` (`auth.tsx`) for the session state. +- **Zod v4**: Use `.issues` instead of `.errors` for validation errors. - **TanStack Router**: Routes are code-generated. Use `--filter frontend build` not raw vite build. ## Docker Deployment (Dokploy) - **Build Context**: `./` (monorepo root) - **Dockerfile**: `Dockerfile` (in root, not `apps/backend/`) -- **Required args**: `DATABASE_URL`, `VITE_API_BASE_URL`, `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY` +- **Required args**: `DATABASE_URL`, `VITE_API_BASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` - **bun.lock must be tracked**: It's in `.gitignore` by default - remove it for Docker builds ## Linting @@ -59,8 +72,9 @@ Biome is used (not ESLint). Config in `biome.json`: **Backend** (`apps/backend/.env`): - `DATABASE_URL` - PostgreSQL connection (required for Prisma) -- `SUPABASE_URL`, `SUPABASE_ANON_KEY` - Auth -- `CORS_ORIGIN` - Frontend URL +- `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` - Auth core +- `APP_BASE_URL` - Frontend URL for trusted origins +- `CORS_ORIGIN` - Frontend URL for CORS policy **Frontend** (`apps/frontend/.env`): - `VITE_*` prefix required (Vite embeds these at build time) diff --git a/apps/frontend/src/features/profile/profile-page.tsx b/apps/frontend/src/features/profile/profile-page.tsx index a5ef6d4..de0712d 100644 --- a/apps/frontend/src/features/profile/profile-page.tsx +++ b/apps/frontend/src/features/profile/profile-page.tsx @@ -15,6 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { UserProfileResponse } from '@repo/api-contract'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; +import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -62,19 +63,27 @@ export function ProfilePage() { }, }); + // Sync form with displayName when session data is loaded or updated elsewhere + useEffect(() => { + if (displayName && !updateProfileForm.formState.isDirty) { + updateProfileForm.reset({ fullName: displayName }); + } + }, [displayName, updateProfileForm]); + const updateProfileMutation = useMutation({ mutationFn: updateProfile, - onSuccess: () => { + onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['user-profile'] }); - updateProfileForm.reset(); + updateProfileForm.reset({ fullName: variables.fullName }); }, }); const updatePasswordMutation = useMutation({ mutationFn: async (data: UpdatePasswordForm) => { - // Note: Supabase doesn't require current password for password update - // The user needs to be recently authenticated - await updatePassword({ password: data.newPassword }); + await updatePassword({ + currentPassword: data.currentPassword, + password: data.newPassword, + }); }, onSuccess: () => { updatePasswordForm.reset(); @@ -118,9 +127,7 @@ export function ProfilePage() { {initials}
-

- {profileQuery.data?.fullName ?? displayName} -

+

{displayName}

{profileQuery.data?.email ?? user?.email}

diff --git a/apps/frontend/src/lib/auth.tsx b/apps/frontend/src/lib/auth.tsx index 4679ab3..7386e54 100644 --- a/apps/frontend/src/lib/auth.tsx +++ b/apps/frontend/src/lib/auth.tsx @@ -20,6 +20,7 @@ type UpdateProfileParams = { }; type UpdatePasswordParams = { + currentPassword?: string; password: string; }; @@ -172,11 +173,11 @@ export function AuthProvider({ children }: PropsWithChildren) { } return { - requiresEmailConfirmation: !signUpData?.session, + requiresEmailConfirmation: !signUpData || !('session' in signUpData), }; }, updateProfile: async ({ fullName }) => { - const { error } = await authClient.user.update({ + const { error } = await authClient.updateUser({ name: fullName, }); @@ -184,10 +185,11 @@ export function AuthProvider({ children }: PropsWithChildren) { throw error; } }, - updatePassword: async ({ password }) => { + updatePassword: async ({ currentPassword, password }) => { // Better Auth uses changePassword for authenticated users const { error } = await authClient.changePassword({ newPassword: password, + currentPassword: currentPassword || '', revokeOtherSessions: true, });