Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages

This commit is contained in:
clawd
2026-02-18 15:47:13 +00:00
parent b0bd3e533f
commit 30e44370a1
32 changed files with 3561 additions and 113 deletions

View File

@@ -1,15 +1,308 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { authService } from '../services/auth.service.js';
import { authMiddleware } from '../middleware/auth.js';
const registerSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long'),
});
const loginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
const updateProfileSchema = z.object({
display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long').optional(),
avatar_url: z.string().url('Invalid avatar URL').or(z.literal('')).optional(),
});
const changePasswordSchema = z.object({
current_password: z.string().min(1, 'Current password is required'),
new_password: z.string().min(8, 'New password must be at least 8 characters'),
});
const refreshTokenSchema = z.object({
refresh_token: z.string().min(1, 'Refresh token is required'),
});
export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
// POST /api/auth/register
app.post('/api/auth/register', async (request, reply) => {
try {
const data = registerSchema.parse(request.body);
const result = await authService.register(data.email, data.password, data.display_name);
// Set refresh token as httpOnly cookie
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
reply.status(201).send({
success: true,
user: result.user,
access_token: result.tokens.accessToken,
});
} catch (error: any) {
if (error.message === 'EMAIL_EXISTS') {
return reply.status(400).send({
success: false,
error: 'EMAIL_EXISTS',
message: 'An account with this email already exists',
});
}
if (error instanceof z.ZodError) {
const firstError = error.errors && error.errors.length > 0 ? error.errors[0] : null;
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: firstError?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Register error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Registration failed',
});
}
});
app.post('/api/auth/register', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
// POST /api/auth/login
app.post('/api/auth/login', async (request, reply) => {
try {
const data = loginSchema.parse(request.body);
const result = await authService.login(data.email, data.password);
// Set refresh token as httpOnly cookie
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
reply.send({
success: true,
user: result.user,
access_token: result.tokens.accessToken,
});
} catch (error: any) {
if (error.message === 'INVALID_CREDENTIALS') {
return reply.status(401).send({
success: false,
error: 'INVALID_CREDENTIALS',
message: 'Invalid email or password',
});
}
if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: error.errors[0]?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Login error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Login failed',
});
}
});
app.get('/api/auth/me', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
// POST /api/auth/logout
app.post('/api/auth/logout', async (request, reply) => {
// Clear refresh token cookie
reply.clearCookie('luna_refresh_token', { path: '/' });
reply.send({
success: true,
message: 'Successfully logged out',
});
});
// GET /api/auth/me (protected)
app.get('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => {
try {
const user = await authService.getProfile(request.user!.id);
if (!user) {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
reply.send({
success: true,
user,
});
} catch (error) {
console.error('Get profile error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to get profile',
});
}
});
// PUT /api/auth/me (protected)
app.put('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => {
try {
const data = updateProfileSchema.parse(request.body);
const user = await authService.updateProfile(request.user!.id, data);
reply.send({
success: true,
user,
});
} catch (error: any) {
if (error.message === 'USER_NOT_FOUND') {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
if (error.message === 'NO_UPDATES_PROVIDED') {
return reply.status(400).send({
success: false,
error: 'NO_UPDATES_PROVIDED',
message: 'No updates provided',
});
}
if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: error.errors[0]?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Update profile error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to update profile',
});
}
});
// PUT /api/auth/me/password (protected)
app.put('/api/auth/me/password', { preHandler: authMiddleware }, async (request, reply) => {
try {
const data = changePasswordSchema.parse(request.body);
await authService.changePassword(request.user!.id, data.current_password, data.new_password);
reply.send({
success: true,
message: 'Password changed successfully',
});
} catch (error: any) {
if (error.message === 'USER_NOT_FOUND') {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
if (error.message === 'INVALID_CURRENT_PASSWORD') {
return reply.status(400).send({
success: false,
error: 'INVALID_CURRENT_PASSWORD',
message: 'Current password is incorrect',
});
}
if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: error.errors[0]?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Change password error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to change password',
});
}
});
// POST /api/auth/refresh (for refreshing access tokens)
app.post('/api/auth/refresh', async (request, reply) => {
try {
const refreshToken = request.cookies.luna_refresh_token;
if (!refreshToken) {
return reply.status(401).send({
success: false,
error: 'MISSING_REFRESH_TOKEN',
message: 'Refresh token required',
});
}
const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
reply.send({
success: true,
user: result.user,
access_token: result.tokens.accessToken,
});
} catch (error: any) {
if (error.message === 'INVALID_REFRESH_TOKEN' || error.message === 'USER_NOT_FOUND') {
// Clear invalid refresh token cookie
reply.clearCookie('luna_refresh_token', { path: '/' });
return reply.status(401).send({
success: false,
error: 'INVALID_REFRESH_TOKEN',
message: 'Invalid or expired refresh token',
});
}
console.error('Refresh token error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to refresh token',
});
}
});
}

View File

@@ -0,0 +1,178 @@
import { FastifyInstance } from 'fastify';
import { authMiddleware } from '../middleware/auth.js';
import { householdService } from '../services/household.service.js';
export async function householdRoutes(app: FastifyInstance) {
// Create a new household
app.post('/api/households', { preHandler: [authMiddleware] }, async (request, reply) => {
const { name } = request.body as { name: string };
if (!name || name.trim() === '') {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Household name is required'
});
}
try {
const household = await householdService.createHousehold(request.user!.id, name.trim());
return reply.status(201).send({
success: true,
data: household
});
} catch (error: any) {
if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') {
return reply.status(409).send({
success: false,
error: 'USER_ALREADY_IN_HOUSEHOLD',
message: 'You are already a member of a household'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to create household'
});
}
});
// Get my household
app.get('/api/households/mine', { preHandler: [authMiddleware] }, async (request, reply) => {
try {
const household = await householdService.getMyHousehold(request.user!.id);
if (!household) {
return reply.status(404).send({
success: false,
error: 'NO_HOUSEHOLD',
message: 'You are not a member of any household'
});
}
return reply.send({
success: true,
data: household
});
} catch (error: any) {
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to get household'
});
}
});
// Join a household with invite code
app.post('/api/households/join', { preHandler: [authMiddleware] }, async (request, reply) => {
const { inviteCode } = request.body as { inviteCode: string };
if (!inviteCode || inviteCode.trim() === '') {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Invite code is required'
});
}
try {
const household = await householdService.joinHousehold(request.user!.id, inviteCode.trim().toUpperCase());
return reply.send({
success: true,
data: household
});
} catch (error: any) {
if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') {
return reply.status(409).send({
success: false,
error: 'USER_ALREADY_IN_HOUSEHOLD',
message: 'You are already a member of a household'
});
}
if (error.message === 'INVALID_INVITE_CODE') {
return reply.status(404).send({
success: false,
error: 'INVALID_INVITE_CODE',
message: 'Invalid invite code'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to join household'
});
}
});
// Regenerate invite code (owner only)
app.post('/api/households/:id/invite', { preHandler: [authMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
try {
const newInviteCode = await householdService.regenerateInviteCode(request.user!.id, id);
return reply.send({
success: true,
data: { invite_code: newInviteCode }
});
} catch (error: any) {
if (error.message === 'ONLY_OWNER_CAN_REGENERATE_INVITE') {
return reply.status(403).send({
success: false,
error: 'FORBIDDEN',
message: 'Only household owners can regenerate invite codes'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to regenerate invite code'
});
}
});
// Leave household
app.delete('/api/households/:id/leave', { preHandler: [authMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
try {
await householdService.leaveHousehold(request.user!.id, id);
return reply.send({
success: true,
message: 'Successfully left household'
});
} catch (error: any) {
if (error.message === 'NOT_HOUSEHOLD_MEMBER') {
return reply.status(404).send({
success: false,
error: 'NOT_HOUSEHOLD_MEMBER',
message: 'You are not a member of this household'
});
}
if (error.message === 'OWNER_CANNOT_LEAVE_WITH_MEMBERS') {
return reply.status(409).send({
success: false,
error: 'OWNER_CANNOT_LEAVE_WITH_MEMBERS',
message: 'Household owners cannot leave while other members remain'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to leave household'
});
}
});
}

View File

@@ -1,33 +1,44 @@
import { FastifyInstance } from 'fastify';
import { optionalAuthMiddleware } from '../middleware/auth.js';
import * as svc from '../services/note.service.js';
export async function noteRoutes(app: FastifyInstance) {
app.get('/api/recipes/:id/notes', async (request) => {
app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { id } = request.params as { id: string };
return svc.listNotes(id);
const userId = request.user?.id;
return svc.listNotes(id, userId);
});
app.post('/api/recipes/:id/notes', async (request, reply) => {
app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
const userId = request.user?.id;
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.createNote(id, content);
const note = svc.createNote(id, content, userId);
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send(note);
});
app.put('/api/notes/:id', async (request, reply) => {
app.put('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
const userId = request.user?.id;
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.updateNote(id, content);
const note = svc.updateNote(id, content, userId);
if (!note) return reply.status(404).send({ error: 'Not found' });
return note;
});
app.delete('/api/notes/:id', async (request, reply) => {
app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteNote(id);
const userId = request.user?.id;
const ok = svc.deleteNote(id, userId);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});

View File

@@ -1,4 +1,5 @@
import { FastifyInstance } from 'fastify';
import { optionalAuthMiddleware, authMiddleware } from '../middleware/auth.js';
import * as svc from '../services/recipe.service.js';
export async function recipeRoutes(app: FastifyInstance) {
@@ -15,8 +16,10 @@ export async function recipeRoutes(app: FastifyInstance) {
return { data: results, total: results.length };
});
app.get('/api/recipes', async (request) => {
app.get('/api/recipes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const query = request.query as any;
const userId = request.user?.id;
return svc.listRecipes({
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
@@ -25,6 +28,7 @@ export async function recipeRoutes(app: FastifyInstance) {
favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined,
difficulty: query.difficulty,
maxTime: query.maxTime ? Number(query.maxTime) : undefined,
userId: userId,
});
});
@@ -56,9 +60,11 @@ export async function recipeRoutes(app: FastifyInstance) {
return { ok: true };
});
app.patch('/api/recipes/:id/favorite', async (request, reply) => {
app.patch('/api/recipes/:id/favorite', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const result = svc.toggleFavorite(id);
const userId = request.user?.id;
const result = svc.toggleFavorite(id, userId);
if (!result) return reply.status(404).send({ error: 'Not found' });
return result;
});

View File

@@ -1,40 +1,92 @@
import { FastifyInstance } from 'fastify';
import { optionalAuthMiddleware } from '../middleware/auth.js';
import * as svc from '../services/shopping.service.js';
export async function shoppingRoutes(app: FastifyInstance) {
app.get('/api/shopping', async () => {
return svc.listItems();
// List shopping items with optional authentication
app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
return svc.listItems(userId, shoppingScope);
});
app.post('/api/shopping/from-recipe/:id', async (request, reply) => {
// Add items from recipe with optional authentication
app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const items = svc.addFromRecipe(id);
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send({ added: items.length });
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
try {
const items = svc.addFromRecipe(id, userId, shoppingScope);
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send({ added: items.length });
} catch (error: any) {
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
return reply.status(400).send({ error: 'You are not a member of any household' });
}
throw error;
}
});
app.post('/api/shopping', async (request, reply) => {
// Add single item with optional authentication
app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
if (!name) return reply.status(400).send({ error: 'name required' });
const item = svc.addItem(name, amount, unit);
return reply.status(201).send(item);
try {
const item = svc.addItem(name, amount, unit, userId, shoppingScope);
return reply.status(201).send(item);
} catch (error: any) {
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
return reply.status(400).send({ error: 'You are not a member of any household' });
}
throw error;
}
});
app.patch('/api/shopping/:id/check', async (request, reply) => {
// Toggle check status with optional authentication
app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const item = svc.toggleCheck(id);
const userId = request.user?.id;
const item = svc.toggleCheck(id, userId);
if (!item) return reply.status(404).send({ error: 'Not found' });
return item;
});
app.delete('/api/shopping/checked', async () => {
const count = svc.deleteChecked();
// Delete all items with optional authentication
app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
const count = svc.deleteAll(userId, shoppingScope);
return { ok: true, deleted: count };
});
app.delete('/api/shopping/:id', async (request, reply) => {
// Delete checked items with optional authentication
app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
const count = svc.deleteChecked(userId, shoppingScope);
return { ok: true, deleted: count };
});
// Delete single item with optional authentication
app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteItem(id);
const userId = request.user?.id;
const ok = svc.deleteItem(id, userId);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});