feat(auth): implement user authentication with JWT and NextAuth

- Added user registration and login functionality with JWT authentication.
- Created auth controller, service, and module in the backend.
- Implemented user model and user products management.
- Integrated NextAuth for session management on the frontend.
- Added middleware for protecting routes and handling public access.
- Updated frontend API routes to include authorization headers.
- Enhanced recipe and user product models to support ownership and visibility.
- Created registration and login pages in the frontend.
- Added necessary types for NextAuth session management.
This commit is contained in:
Nils-Johan Gynther
2026-04-17 19:57:08 +02:00
parent 4c0411a7f2
commit ce0cc6fbf0
55 changed files with 1006 additions and 137 deletions
@@ -1,5 +1,6 @@
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
@@ -47,6 +48,10 @@ export class CreateRecipeDto {
@Min(1)
servings?: number;
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
+21 -10
View File
@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch, Request } from '@nestjs/common';
import { IsString } from 'class-validator';
import { RecipesService } from './recipes.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
class UpdateImageDto {
@IsString()
@@ -19,8 +20,8 @@ export class RecipesController {
}
@Get()
findAll() {
return this.recipesService.findAll();
findAll(@CurrentUser() user: { userId: number }) {
return this.recipesService.findAll(user.userId);
}
@Get(':id/inventory-preview')
@@ -29,27 +30,37 @@ export class RecipesController {
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.recipesService.findOne(id);
findOne(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.findOne(id, user.userId);
}
@Post()
async create(@Body() createRecipeDto: CreateRecipeDto) {
return this.recipesService.create(createRecipeDto);
async create(
@Body() createRecipeDto: CreateRecipeDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.create(createRecipeDto, user.userId);
}
@Patch(':id')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() createRecipeDto: CreateRecipeDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.update(id, createRecipeDto);
return this.recipesService.update(id, createRecipeDto, user.userId);
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id', ParseIntPipe) id: number) {
return this.recipesService.remove(id);
async remove(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.remove(id, user.userId);
}
@Post(':id/image')
+38 -8
View File
@@ -290,27 +290,45 @@ export class RecipesService {
};
}
async findAll() {
async findAll(userId: number) {
return this.prisma.recipe.findMany({
where: {
OR: [
{ isPublic: true },
{ ownerId: userId },
{ shares: { some: { userId } } },
],
},
include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
},
},
owner: { select: { id: true, username: true } },
shares: { select: { userId: true } },
},
});
}
async findOne(id: number) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
async findOne(id: number, userId: number) {
const recipe = await this.prisma.recipe.findFirst({
where: {
id,
OR: [
{ isPublic: true },
{ ownerId: userId },
{ shares: { some: { userId } } },
],
},
include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
},
},
owner: { select: { id: true, username: true } },
shares: { select: { userId: true } },
},
});
@@ -321,8 +339,8 @@ export class RecipesService {
return recipe;
}
async update(id: number, updateRecipeDto: CreateRecipeDto) {
// Verifiera att receptet finns
async update(id: number, updateRecipeDto: CreateRecipeDto, userId: number) {
// Verifiera att receptet finns och att användaren äger det
const existingRecipe = await this.prisma.recipe.findUnique({
where: { id },
});
@@ -331,6 +349,11 @@ export class RecipesService {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
// Tillåt uppdatering om användaren är ägare ELLER om receptet är publikt utan ägare
if (existingRecipe.ownerId !== null && existingRecipe.ownerId !== userId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
// Ta bort gamla ingredienser
await this.prisma.recipeIngredient.deleteMany({
where: { recipeId: id },
@@ -344,6 +367,7 @@ export class RecipesService {
description: updateRecipeDto.description || null,
instructions: updateRecipeDto.instructions || null,
servings: updateRecipeDto.servings ?? null,
...(updateRecipeDto.isPublic !== undefined && { isPublic: updateRecipeDto.isPublic }),
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
ingredients: {
create: updateRecipeDto.ingredients.map((ingredient) => ({
@@ -366,7 +390,7 @@ export class RecipesService {
return recipe;
}
async remove(id: number) {
async remove(id: number, userId: number) {
const existingRecipe = await this.prisma.recipe.findUnique({
where: { id },
});
@@ -375,6 +399,10 @@ export class RecipesService {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
if (existingRecipe.ownerId !== null && existingRecipe.ownerId !== userId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } });
await this.prisma.recipe.delete({ where: { id } });
}
@@ -394,7 +422,7 @@ export class RecipesService {
});
}
async create(createRecipeDto: CreateRecipeDto) {
async create(createRecipeDto: CreateRecipeDto, userId: number) {
// Om imageUrl är en extern URL — ladda ner och optimera
let imageUrl: string | null = createRecipeDto.imageUrl || null;
if (imageUrl && imageUrl.startsWith('http')) {
@@ -413,6 +441,8 @@ export class RecipesService {
instructions: createRecipeDto.instructions || null,
imageUrl,
servings: createRecipeDto.servings ?? null,
ownerId: userId,
isPublic: false,
ingredients: {
create: createRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId,