feat: migrate authentication to Better Auth and update profile management logic

This commit is contained in:
Jose Selesan
2026-04-16 21:10:38 -03:00
parent 6dc8cae3a8
commit f98a0c2c02
3 changed files with 40 additions and 17 deletions

View File

@@ -33,18 +33,31 @@ packages/api-contract/ # Shared Zod schemas, types, route definitions
2. Implement handler in `apps/backend` 2. Implement handler in `apps/backend`
3. Consume from `apps/frontend` via workspace import `@repo/api-contract` 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 ## Key Quirks
- **Prisma 7**: Uses `prisma.config.ts`, requires `DATABASE_URL` env var at generate time - **Prisma 7**: Uses `prisma.config.ts`, requires `DATABASE_URL` env var at generate time.
- **Zod v4**: Use `.issues` instead of `.errors` for validation errors - **Gravatar Fallback**: Users without an `image` get an automatic Gravatar Identicon.
- **Generated files**: `routeTree.gen.ts` and `tsconfig*.json` are auto-generated (ignored by Biome) - 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. - **TanStack Router**: Routes are code-generated. Use `--filter frontend build` not raw vite build.
## Docker Deployment (Dokploy) ## Docker Deployment (Dokploy)
- **Build Context**: `./` (monorepo root) - **Build Context**: `./` (monorepo root)
- **Dockerfile**: `Dockerfile` (in root, not `apps/backend/`) - **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 - **bun.lock must be tracked**: It's in `.gitignore` by default - remove it for Docker builds
## Linting ## Linting
@@ -59,8 +72,9 @@ Biome is used (not ESLint). Config in `biome.json`:
**Backend** (`apps/backend/.env`): **Backend** (`apps/backend/.env`):
- `DATABASE_URL` - PostgreSQL connection (required for Prisma) - `DATABASE_URL` - PostgreSQL connection (required for Prisma)
- `SUPABASE_URL`, `SUPABASE_ANON_KEY` - Auth - `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` - Auth core
- `CORS_ORIGIN` - Frontend URL - `APP_BASE_URL` - Frontend URL for trusted origins
- `CORS_ORIGIN` - Frontend URL for CORS policy
**Frontend** (`apps/frontend/.env`): **Frontend** (`apps/frontend/.env`):
- `VITE_*` prefix required (Vite embeds these at build time) - `VITE_*` prefix required (Vite embeds these at build time)

View File

@@ -15,6 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import type { UserProfileResponse } from '@repo/api-contract'; import type { UserProfileResponse } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; 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({ const updateProfileMutation = useMutation({
mutationFn: updateProfile, mutationFn: updateProfile,
onSuccess: () => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['user-profile'] }); queryClient.invalidateQueries({ queryKey: ['user-profile'] });
updateProfileForm.reset(); updateProfileForm.reset({ fullName: variables.fullName });
}, },
}); });
const updatePasswordMutation = useMutation({ const updatePasswordMutation = useMutation({
mutationFn: async (data: UpdatePasswordForm) => { mutationFn: async (data: UpdatePasswordForm) => {
// Note: Supabase doesn't require current password for password update await updatePassword({
// The user needs to be recently authenticated currentPassword: data.currentPassword,
await updatePassword({ password: data.newPassword }); password: data.newPassword,
});
}, },
onSuccess: () => { onSuccess: () => {
updatePasswordForm.reset(); updatePasswordForm.reset();
@@ -118,9 +127,7 @@ export function ProfilePage() {
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">{displayName}</h1>
{profileQuery.data?.fullName ?? displayName}
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{profileQuery.data?.email ?? user?.email} {profileQuery.data?.email ?? user?.email}
</p> </p>

View File

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