feat: migrate authentication to Better Auth and update profile management logic
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user