diff --git a/backend/package.json b/backend/package.json index 12d9327d..cb7034fc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,8 +15,13 @@ "dependencies": { "@nestjs/common": "^10.3.0", "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", "@prisma/client": "6.12.0", + "bcryptjs": "^2.4.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "multer": "^1.4.5-lts.2", @@ -31,9 +36,11 @@ "@nestjs/cli": "^10.3.0", "@nestjs/schematics": "^10.1.1", "@nestjs/testing": "^10.3.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.15.29", + "@types/passport-jwt": "^4.0.1", "@types/pdf-parse": "^1.1.5", "@types/uuid": "^10.0.0", "prisma": "6.12.0", diff --git a/backend/prisma/migrations/20260417200000_add_auth_and_recipe_visibility/migration.sql b/backend/prisma/migrations/20260417200000_add_auth_and_recipe_visibility/migration.sql new file mode 100644 index 00000000..ea2897ae --- /dev/null +++ b/backend/prisma/migrations/20260417200000_add_auth_and_recipe_visibility/migration.sql @@ -0,0 +1,55 @@ +-- CreateTable: User +CREATE TABLE `User` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `username` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `passwordHash` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `User_username_key` (`username`), + UNIQUE INDEX `User_email_key` (`email`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable: UserProduct +CREATE TABLE `UserProduct` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `productId` INTEGER NOT NULL, + `note` TEXT NULL, + `preferredBrand` VARCHAR(191) NULL, + `preferredStore` VARCHAR(191) NULL, + `isPrivate` BOOLEAN NOT NULL DEFAULT false, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `UserProduct_userId_productId_key` (`userId`, `productId`), + INDEX `UserProduct_userId_idx` (`userId`), + INDEX `UserProduct_productId_idx` (`productId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable: RecipeShare +CREATE TABLE `RecipeShare` ( + `recipeId` INTEGER NOT NULL, + `userId` INTEGER NOT NULL, + PRIMARY KEY (`recipeId`, `userId`), + INDEX `RecipeShare_userId_idx` (`userId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AlterTable: Product add ownerId +ALTER TABLE `Product` ADD COLUMN `ownerId` INTEGER NULL; + +-- AlterTable: Recipe add ownerId and isPublic +ALTER TABLE `Recipe` ADD COLUMN `ownerId` INTEGER NULL, + ADD COLUMN `isPublic` BOOLEAN NOT NULL DEFAULT false; + +-- Make all existing recipes (without owner) public +UPDATE `Recipe` SET `isPublic` = true WHERE `ownerId` IS NULL; + +-- AddForeignKey +ALTER TABLE `UserProduct` ADD CONSTRAINT `UserProduct_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `UserProduct` ADD CONSTRAINT `UserProduct_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `RecipeShare` ADD CONSTRAINT `RecipeShare_recipeId_fkey` FOREIGN KEY (`recipeId`) REFERENCES `Recipe`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `RecipeShare` ADD CONSTRAINT `RecipeShare_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `Product` ADD CONSTRAINT `Product_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE `Recipe` ADD CONSTRAINT `Recipe_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 56f169e3..db5e674d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -7,6 +7,19 @@ datasource db { url = env("DATABASE_URL") } +model User { + id Int @id @default(autoincrement()) + username String @unique + email String @unique + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userProducts UserProduct[] + ownedRecipes Recipe[] @relation("RecipeOwner") + sharedRecipes RecipeShare[] +} + model Product { id Int @id @default(autoincrement()) name String @@ -26,6 +39,28 @@ model Product { receiptAliases ReceiptAlias[] tags ProductTag[] nutrition Nutrition? + ownerId Int? + owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) + userProducts UserProduct[] +} + +model UserProduct { + id Int @id @default(autoincrement()) + userId Int + productId Int + note String? @db.Text + preferredBrand String? + preferredStore String? + isPrivate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@unique([userId, productId]) + @@index([userId]) + @@index([productId]) } model InventoryItem { @@ -73,11 +108,25 @@ model Recipe { instructions String? @db.Text imageUrl String? servings Int? + isPublic Boolean @default(false) + ownerId Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - ingredients RecipeIngredient[] + owner User? @relation("RecipeOwner", fields: [ownerId], references: [id], onDelete: SetNull) + ingredients RecipeIngredient[] mealPlanEntries MealPlanEntry[] + shares RecipeShare[] +} + +model RecipeShare { + recipeId Int + userId Int + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([recipeId, userId]) + @@index([userId]) } model RecipeIngredient { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4473b628..e830224c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { HealthModule } from './health/health.module'; import { PrismaModule } from './prisma/prisma.module'; import { ProductsModule } from './products/products.module'; @@ -9,6 +10,10 @@ import { PantryModule } from './pantry/pantry.module'; import { MealPlanModule } from './meal-plan/meal-plan.module'; import { ReceiptImportModule } from './receipt-import/receipt-import.module'; import { ReceiptAliasModule } from './receipt-alias/receipt-alias.module'; +import { AuthModule } from './auth/auth.module'; +import { UsersModule } from './users/users.module'; +import { UserProductsModule } from './user-products/user-products.module'; +import { JwtAuthGuard } from './auth/jwt-auth.guard'; @Module({ @@ -23,6 +28,15 @@ import { ReceiptAliasModule } from './receipt-alias/receipt-alias.module'; MealPlanModule, ReceiptImportModule, ReceiptAliasModule, + AuthModule, + UsersModule, + UserProductsModule, + ], + providers: [ + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, ], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 00000000..9afeb65b --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { Public } from './decorators/public.decorator'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('register') + register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('login') + login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 00000000..2a24e820 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './jwt.strategy'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET ?? 'changeme', + signOptions: { expiresIn: '7d' }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 00000000..4c5bfeb1 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,51 @@ +import { + Injectable, + ConflictException, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import { UsersService } from '../users/users.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + async register(dto: RegisterDto) { + const existing = await this.usersService.findByUsername(dto.username); + if (existing) throw new ConflictException('Användarnamnet är redan taget'); + + const passwordHash = await bcrypt.hash(dto.password, 12); + const user = await this.usersService.create({ + username: dto.username, + email: dto.email, + passwordHash, + }); + + return this.issueToken(user.id, user.username); + } + + async login(dto: LoginDto) { + const user = await this.usersService.findByUsername(dto.username); + if (!user) throw new UnauthorizedException('Felaktigt användarnamn eller lösenord'); + + const valid = await bcrypt.compare(dto.password, user.passwordHash); + if (!valid) throw new UnauthorizedException('Felaktigt användarnamn eller lösenord'); + + return this.issueToken(user.id, user.username); + } + + private issueToken(userId: number, username: string) { + const payload = { sub: userId, username }; + return { + accessToken: this.jwtService.sign(payload), + userId, + username, + }; + } +} diff --git a/backend/src/auth/decorators/current-user.decorator.ts b/backend/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 00000000..1d82f089 --- /dev/null +++ b/backend/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user as { userId: number; username: string }; + }, +); diff --git a/backend/src/auth/decorators/public.decorator.ts b/backend/src/auth/decorators/public.decorator.ts new file mode 100644 index 00000000..39ebd506 --- /dev/null +++ b/backend/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 00000000..e57d8371 --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class LoginDto { + @IsString() + username: string; + + @IsString() + password: string; +} diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts new file mode 100644 index 00000000..1b2f493f --- /dev/null +++ b/backend/src/auth/dto/register.dto.ts @@ -0,0 +1,13 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class RegisterDto { + @IsString() + username: string; + + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; +} diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 00000000..cd8fb83f --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,20 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from './decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } +} diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts new file mode 100644 index 00000000..e7011217 --- /dev/null +++ b/backend/src/auth/jwt.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET ?? 'changeme', + }); + } + + async validate(payload: { sub: number; username: string }) { + return { userId: payload.sub, username: payload.username }; + } +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index b8661d45..27a6a74f 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get, HttpCode, Res } from '@nestjs/common'; import { Response } from 'express'; import { HealthService } from './health.service'; +import { Public } from '../auth/decorators/public.decorator'; +@Public() @Controller('health') export class HealthController { constructor(private readonly healthService: HealthService) {} diff --git a/backend/src/recipes/dto/create-recipe.dto.ts b/backend/src/recipes/dto/create-recipe.dto.ts index d89dbb3b..dc19370c 100644 --- a/backend/src/recipes/dto/create-recipe.dto.ts +++ b/backend/src/recipes/dto/create-recipe.dto.ts @@ -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 }) diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts index 435e5a9f..bcd56dd9 100644 --- a/backend/src/recipes/recipes.controller.ts +++ b/backend/src/recipes/recipes.controller.ts @@ -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') diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 53119e8c..20c6be01 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -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, diff --git a/backend/src/user-products/dto/upsert-user-product.dto.ts b/backend/src/user-products/dto/upsert-user-product.dto.ts new file mode 100644 index 00000000..37835cea --- /dev/null +++ b/backend/src/user-products/dto/upsert-user-product.dto.ts @@ -0,0 +1,22 @@ +import { IsInt, IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class UpsertUserProductDto { + @IsInt() + productId: number; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsString() + preferredBrand?: string; + + @IsOptional() + @IsString() + preferredStore?: string; + + @IsOptional() + @IsBoolean() + isPrivate?: boolean; +} diff --git a/backend/src/user-products/user-products.controller.ts b/backend/src/user-products/user-products.controller.ts new file mode 100644 index 00000000..9bf0de57 --- /dev/null +++ b/backend/src/user-products/user-products.controller.ts @@ -0,0 +1,38 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + ParseIntPipe, +} from '@nestjs/common'; +import { UserProductsService } from './user-products.service'; +import { UpsertUserProductDto } from './dto/upsert-user-product.dto'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; + +@Controller('user-products') +export class UserProductsController { + constructor(private readonly service: UserProductsService) {} + + @Get() + findAll(@CurrentUser() user: { userId: number }) { + return this.service.findAll(user.userId); + } + + @Post() + upsert( + @CurrentUser() user: { userId: number }, + @Body() dto: UpsertUserProductDto, + ) { + return this.service.upsert(user.userId, dto); + } + + @Delete(':productId') + remove( + @CurrentUser() user: { userId: number }, + @Param('productId', ParseIntPipe) productId: number, + ) { + return this.service.remove(user.userId, productId); + } +} diff --git a/backend/src/user-products/user-products.module.ts b/backend/src/user-products/user-products.module.ts new file mode 100644 index 00000000..6a37bcc0 --- /dev/null +++ b/backend/src/user-products/user-products.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UserProductsController } from './user-products.controller'; +import { UserProductsService } from './user-products.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [UserProductsController], + providers: [UserProductsService], +}) +export class UserProductsModule {} diff --git a/backend/src/user-products/user-products.service.ts b/backend/src/user-products/user-products.service.ts new file mode 100644 index 00000000..55420511 --- /dev/null +++ b/backend/src/user-products/user-products.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpsertUserProductDto } from './dto/upsert-user-product.dto'; + +const PRODUCT_INCLUDE = { + product: { + include: { + nutrition: true, + tags: { include: { tag: true } }, + }, + }, +}; + +@Injectable() +export class UserProductsService { + constructor(private readonly prisma: PrismaService) {} + + findAll(userId: number) { + return this.prisma.userProduct.findMany({ + where: { userId }, + include: PRODUCT_INCLUDE, + orderBy: { updatedAt: 'desc' }, + }); + } + + findOne(userId: number, productId: number) { + return this.prisma.userProduct.findUnique({ + where: { userId_productId: { userId, productId } }, + include: PRODUCT_INCLUDE, + }); + } + + upsert(userId: number, dto: UpsertUserProductDto) { + const { productId, ...data } = dto; + return this.prisma.userProduct.upsert({ + where: { userId_productId: { userId, productId } }, + create: { userId, productId, ...data }, + update: data, + include: PRODUCT_INCLUDE, + }); + } + + remove(userId: number, productId: number) { + return this.prisma.userProduct.delete({ + where: { userId_productId: { userId, productId } }, + }); + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts new file mode 100644 index 00000000..d9c11340 --- /dev/null +++ b/backend/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts new file mode 100644 index 00000000..31b214a6 --- /dev/null +++ b/backend/src/users/users.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + findByUsername(username: string) { + return this.prisma.user.findUnique({ where: { username } }); + } + + findById(id: number) { + return this.prisma.user.findUnique({ where: { id } }); + } + + create(data: { username: string; email: string; passwordHash: string }) { + return this.prisma.user.create({ data }); + } +} diff --git a/compose.yml b/compose.yml index aa89f4eb..df58d943 100644 --- a/compose.yml +++ b/compose.yml @@ -13,6 +13,8 @@ services: NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL}" NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL}" NEXT_PUBLIC_API_URL_INTERNAL: "http://recipe-api:8080" + AUTH_SECRET: "${AUTH_SECRET}" + AUTH_URL: "${NEXT_PUBLIC_APP_URL}" volumes: - recipe_images:/app/public/images depends_on: @@ -39,6 +41,7 @@ services: NODE_ENV: "production" DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}" MISTRAL_API_KEY: "${MISTRAL_API_KEY}" + JWT_SECRET: "${JWT_SECRET}" volumes: - recipe_images:/app/recipe-images depends_on: diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index 89fc171d..a12bfbcf 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -1,6 +1,20 @@ import Link from 'next/link'; +import { auth, signOut } from '../auth'; + +const linkStyle: React.CSSProperties = { + padding: '0.5rem 0.75rem', + background: '#fff', + border: '1px solid #ddd', + borderRadius: '4px', + textDecoration: 'none', + color: '#0070f3', + fontSize: '0.9rem', + fontWeight: 500, +}; + +export default async function Navigation() { + const session = await auth(); -export default function Navigation() { return ( ); } + diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts index 89710eb7..8864c6dd 100644 --- a/frontend/app/admin/products/actions.ts +++ b/frontend/app/admin/products/actions.ts @@ -2,6 +2,7 @@ import { revalidatePath } from 'next/cache'; import { API_BASE } from '../../../lib/api'; +import { getAuthHeaders } from '../../../lib/auth-headers'; export async function updateProduct(formData: FormData) { const id = Number(formData.get('id')); @@ -20,7 +21,7 @@ export async function updateProduct(formData: FormData) { const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) }, body: JSON.stringify({ name: name || undefined, canonicalName: canonicalName || undefined, @@ -42,7 +43,7 @@ export async function updateProduct(formData: FormData) { export async function setProductTags(productId: number, tags: string[]) { const res = await fetch(`${API_BASE}/api/products/${productId}/tags`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) }, body: JSON.stringify({ tags }), cache: 'no-store', }); @@ -58,6 +59,7 @@ export async function setProductTags(productId: number, tags: string[]) { export async function deleteProduct(id: number) { const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'DELETE', + headers: { ...(await getAuthHeaders()) }, cache: 'no-store', }); diff --git a/frontend/app/api/admin/merge-preview-proxy/route.ts b/frontend/app/api/admin/merge-preview-proxy/route.ts index ea50dbb7..35a01c47 100644 --- a/frontend/app/api/admin/merge-preview-proxy/route.ts +++ b/frontend/app/api/admin/merge-preview-proxy/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const sourceProductId = request.nextUrl.searchParams.get('sourceProductId'); const targetProductId = request.nextUrl.searchParams.get('targetProductId'); @@ -11,6 +13,7 @@ export async function GET(request: NextRequest) { `${API_BASE}/api/products/merge-preview?sourceProductId=${sourceProductId}&targetProductId=${targetProductId}`, { method: 'GET', + headers: { ...authHeaders }, cache: 'no-store', }, ); diff --git a/frontend/app/api/auth-register/route.ts b/frontend/app/api/auth-register/route.ts new file mode 100644 index 00000000..70ffef5a --- /dev/null +++ b/frontend/app/api/auth-register/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function POST(request: NextRequest) { + const body = await request.json(); + const res = await fetch(`${API_BASE}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..673d1f43 --- /dev/null +++ b/frontend/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '../../../../auth'; + +export const { GET, POST } = handlers; diff --git a/frontend/app/api/inventory-history-proxy/route.ts b/frontend/app/api/inventory-history-proxy/route.ts index 532eca50..68fac4d2 100644 --- a/frontend/app/api/inventory-history-proxy/route.ts +++ b/frontend/app/api/inventory-history-proxy/route.ts @@ -1,13 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const id = request.nextUrl.searchParams.get('id'); const res = await fetch(`${API_BASE}/api/inventory/${id}/consumption-history`, { method: 'GET', + headers: { ...authHeaders }, cache: 'no-store', }); diff --git a/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts b/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts index a6530213..077db2e8 100644 --- a/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts +++ b/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const { searchParams } = request.nextUrl; const from = searchParams.get('from'); const to = searchParams.get('to'); const res = await fetch(`${API_BASE}/api/meal-plan/inventory-compare?from=${from}&to=${to}`, { + headers: { ...authHeaders }, cache: 'no-store', }); const text = await res.text(); diff --git a/frontend/app/api/meal-plan-proxy/route.ts b/frontend/app/api/meal-plan-proxy/route.ts index 50185cd7..fdc83a01 100644 --- a/frontend/app/api/meal-plan-proxy/route.ts +++ b/frontend/app/api/meal-plan-proxy/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const { searchParams } = request.nextUrl; const query = searchParams.toString(); const res = await fetch(`${API_BASE}/api/meal-plan${query ? `?${query}` : ''}`, { + headers: { ...authHeaders }, cache: 'no-store', }); const text = await res.text(); @@ -16,10 +19,11 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const body = await request.text(); const res = await fetch(`${API_BASE}/api/meal-plan`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body, cache: 'no-store', }); @@ -31,9 +35,11 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const date = request.nextUrl.searchParams.get('date'); const res = await fetch(`${API_BASE}/api/meal-plan/${date}`, { method: 'DELETE', + headers: { ...authHeaders }, cache: 'no-store', }); return new NextResponse(null, { status: res.status }); diff --git a/frontend/app/api/meal-plan-proxy/shopping/route.ts b/frontend/app/api/meal-plan-proxy/shopping/route.ts index b7dcc623..2de22ce8 100644 --- a/frontend/app/api/meal-plan-proxy/shopping/route.ts +++ b/frontend/app/api/meal-plan-proxy/shopping/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const { searchParams } = request.nextUrl; const from = searchParams.get('from'); const to = searchParams.get('to'); const res = await fetch(`${API_BASE}/api/meal-plan/shopping-list?from=${from}&to=${to}`, { + headers: { ...authHeaders }, cache: 'no-store', }); const text = await res.text(); diff --git a/frontend/app/api/parse-markdown-proxy/route.ts b/frontend/app/api/parse-markdown-proxy/route.ts index 2dd24d2a..4c88d38e 100644 --- a/frontend/app/api/parse-markdown-proxy/route.ts +++ b/frontend/app/api/parse-markdown-proxy/route.ts @@ -1,13 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function POST(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const body = await request.text(); const res = await fetch(`${API_BASE}/api/recipes/parse-markdown`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body, cache: 'no-store', }); diff --git a/frontend/app/api/products/route.ts b/frontend/app/api/products/route.ts index 6b1dc312..180a9db5 100644 --- a/frontend/app/api/products/route.ts +++ b/frontend/app/api/products/route.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const res = await fetch(`${API_BASE}/api/products`, { method: 'GET', + headers: { ...authHeaders }, cache: 'no-store', }); diff --git a/frontend/app/api/quick-import-proxy/route.ts b/frontend/app/api/quick-import-proxy/route.ts index 7d0f1abb..3df6bc52 100644 --- a/frontend/app/api/quick-import-proxy/route.ts +++ b/frontend/app/api/quick-import-proxy/route.ts @@ -1,17 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; export async function POST(request: NextRequest) { try { const contentType = request.headers.get('content-type') ?? ''; const isMultipart = contentType.includes('multipart/form-data'); const backendUrl = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + const authHeaders = await getAuthHeaders(); const response = await fetch(`${backendUrl}/api/quick-import`, { method: 'POST', body: isMultipart ? await request.formData() : JSON.stringify(await request.json()), - headers: isMultipart ? undefined : { 'Content-Type': 'application/json' }, + headers: isMultipart ? { ...authHeaders } : { 'Content-Type': 'application/json', ...authHeaders }, cache: 'no-store', }); diff --git a/frontend/app/api/receipt-alias-proxy/route.ts b/frontend/app/api/receipt-alias-proxy/route.ts index 06664bdd..97132343 100644 --- a/frontend/app/api/receipt-alias-proxy/route.ts +++ b/frontend/app/api/receipt-alias-proxy/route.ts @@ -1,10 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET() { - const res = await fetch(`${API_BASE}/api/receipt-aliases`, { cache: 'no-store' }); + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/receipt-aliases`, { + headers: { ...authHeaders }, + cache: 'no-store', + }); const text = await res.text(); return new NextResponse(text, { status: res.status, @@ -13,10 +18,11 @@ export async function GET() { } export async function POST(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const body = await request.json(); const res = await fetch(`${API_BASE}/api/receipt-aliases`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify(body), }); const text = await res.text(); @@ -27,9 +33,11 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const id = request.nextUrl.searchParams.get('id'); const res = await fetch(`${API_BASE}/api/receipt-aliases/${id}`, { method: 'DELETE', + headers: { ...authHeaders }, }); return new NextResponse(null, { status: res.status }); } diff --git a/frontend/app/api/receipt-import-proxy/route.ts b/frontend/app/api/receipt-import-proxy/route.ts index 3eaa9191..d1ae91a6 100644 --- a/frontend/app/api/receipt-import-proxy/route.ts +++ b/frontend/app/api/receipt-import-proxy/route.ts @@ -1,13 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function POST(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const formData = await request.formData(); const res = await fetch(`${API_BASE}/api/receipt-import`, { method: 'POST', + headers: { ...authHeaders }, body: formData, }); diff --git a/frontend/app/api/recipe-preview-proxy/route.ts b/frontend/app/api/recipe-preview-proxy/route.ts index 41e56286..efccff27 100644 --- a/frontend/app/api/recipe-preview-proxy/route.ts +++ b/frontend/app/api/recipe-preview-proxy/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const id = request.nextUrl.searchParams.get('id'); if (!id) { @@ -14,6 +16,7 @@ export async function GET(request: NextRequest) { const res = await fetch(`${API_BASE}/api/recipes/${id}/inventory-preview`, { method: 'GET', + headers: { ...authHeaders }, cache: 'no-store', }); diff --git a/frontend/app/api/recipes/[id]/image/route.ts b/frontend/app/api/recipes/[id]/image/route.ts index 32740840..f7ce3108 100644 --- a/frontend/app/api/recipes/[id]/image/route.ts +++ b/frontend/app/api/recipes/[id]/image/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; @@ -7,11 +8,12 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; + const authHeaders = await getAuthHeaders(); const body = await request.text(); const res = await fetch(`${API_BASE}/api/recipes/${id}/image`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body, cache: 'no-store', }); diff --git a/frontend/app/api/recipes/[id]/route.ts b/frontend/app/api/recipes/[id]/route.ts index f2df7c6b..69127c6b 100644 --- a/frontend/app/api/recipes/[id]/route.ts +++ b/frontend/app/api/recipes/[id]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; @@ -7,7 +8,11 @@ export async function GET( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const res = await fetch(`${API_BASE}/api/recipes/${id}`, { cache: 'no-store' }); + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/recipes/${id}`, { + headers: { ...authHeaders }, + cache: 'no-store', + }); const text = await res.text(); return new NextResponse(text, { status: res.status, @@ -20,10 +25,11 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; + const authHeaders = await getAuthHeaders(); const body = await request.json(); const res = await fetch(`${API_BASE}/api/recipes/${id}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify(body), cache: 'no-store', }); @@ -39,8 +45,10 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; + const authHeaders = await getAuthHeaders(); const res = await fetch(`${API_BASE}/api/recipes/${id}`, { method: 'DELETE', + headers: { ...authHeaders }, cache: 'no-store', }); return new NextResponse(null, { status: res.status }); diff --git a/frontend/app/api/recipes/route.ts b/frontend/app/api/recipes/route.ts index dfec35fb..92eaafde 100644 --- a/frontend/app/api/recipes/route.ts +++ b/frontend/app/api/recipes/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; export async function GET() { + const authHeaders = await getAuthHeaders(); const res = await fetch(`${API_BASE}/api/recipes`, { + headers: { ...authHeaders }, cache: 'no-store', }); const data = await res.json(); @@ -11,10 +14,11 @@ export async function GET() { } export async function POST(request: NextRequest) { + const authHeaders = await getAuthHeaders(); const body = await request.json(); const res = await fetch(`${API_BASE}/api/recipes`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify(body), cache: 'no-store', }); diff --git a/frontend/app/api/user-products/[productId]/route.ts b/frontend/app/api/user-products/[productId]/route.ts new file mode 100644 index 00000000..7597c7ae --- /dev/null +++ b/frontend/app/api/user-products/[productId]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../lib/auth-headers'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const { productId } = await params; + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/user-products/${productId}`, { + method: 'DELETE', + headers: { ...authHeaders }, + }); + return new NextResponse(null, { status: res.status }); +} diff --git a/frontend/app/api/user-products/route.ts b/frontend/app/api/user-products/route.ts new file mode 100644 index 00000000..3023fb75 --- /dev/null +++ b/frontend/app/api/user-products/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function GET() { + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/user-products`, { + headers: { ...authHeaders }, + cache: 'no-store', + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function POST(request: NextRequest) { + const authHeaders = await getAuthHeaders(); + const body = await request.json(); + const res = await fetch(`${API_BASE}/api/user-products`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify(body), + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/baslager/actions.ts b/frontend/app/baslager/actions.ts index aa4c9ca4..cac64975 100644 --- a/frontend/app/baslager/actions.ts +++ b/frontend/app/baslager/actions.ts @@ -2,11 +2,12 @@ import { revalidatePath } from 'next/cache'; import { API_BASE } from '../../lib/api'; +import { getAuthHeaders } from '../../lib/auth-headers'; export async function addPantryItem(productId: number) { const res = await fetch(`${API_BASE}/api/pantry`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) }, body: JSON.stringify({ productId }), cache: 'no-store', }); @@ -22,6 +23,7 @@ export async function addPantryItem(productId: number) { export async function removePantryItem(id: number) { const res = await fetch(`${API_BASE}/api/pantry/${id}`, { method: 'DELETE', + headers: { ...(await getAuthHeaders()) }, cache: 'no-store', }); diff --git a/frontend/app/inventory/actions.ts b/frontend/app/inventory/actions.ts index dd0c652c..b5bb39cb 100644 --- a/frontend/app/inventory/actions.ts +++ b/frontend/app/inventory/actions.ts @@ -2,14 +2,17 @@ import { revalidatePath } from 'next/cache'; import { API_BASE } from '../../lib/api'; +import { getAuthHeaders } from '../../lib/auth-headers'; export async function createProduct(formData: FormData) { const name = String(formData.get('name') || '').trim(); + const authHeaders = await getAuthHeaders(); const res = await fetch(`${API_BASE}/api/products`, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...authHeaders, }, body: JSON.stringify({ name }), cache: 'no-store', @@ -51,6 +54,7 @@ export async function createInventoryItem(formData: FormData) { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(await getAuthHeaders()), }, body: JSON.stringify(payload), cache: 'no-store', @@ -91,6 +95,7 @@ export async function updateInventoryItem(formData: FormData) { method: 'PATCH', headers: { 'Content-Type': 'application/json', + ...(await getAuthHeaders()), }, body: JSON.stringify(payload), cache: 'no-store', @@ -112,6 +117,7 @@ export async function updateCanonicalName(formData: FormData) { method: 'PATCH', headers: { 'Content-Type': 'application/json', + ...(await getAuthHeaders()), }, body: JSON.stringify({ canonicalName }), cache: 'no-store', @@ -133,6 +139,7 @@ export async function mergeProducts(formData: FormData) { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(await getAuthHeaders()), }, body: JSON.stringify({ sourceProductId, @@ -166,6 +173,7 @@ export async function consumeInventoryItem(formData: FormData) { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(await getAuthHeaders()), }, body: JSON.stringify(payload), cache: 'no-store', diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 00000000..00a2b692 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl') ?? '/'; + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + const result = await signIn('credentials', { + username, + password, + redirect: false, + }); + setLoading(false); + if (result?.error) { + setError('Felaktigt användarnamn eller lösenord'); + } else { + router.push(callbackUrl); + router.refresh(); + } + } + + return ( +
+

Logga in

+
+
+ + setUsername(e.target.value)} + required + autoComplete="username" + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid #ccc', fontSize: '1rem' }} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid #ccc', fontSize: '1rem' }} + /> +
+ {error &&

{error}

} + +

+ Inget konto? Skapa konto +

+
+
+ ); +} diff --git a/frontend/app/recipes/[id]/RecipeDetailClient.tsx b/frontend/app/recipes/[id]/RecipeDetailClient.tsx index e344feb9..02a8aa7c 100644 --- a/frontend/app/recipes/[id]/RecipeDetailClient.tsx +++ b/frontend/app/recipes/[id]/RecipeDetailClient.tsx @@ -79,6 +79,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: instructions: initialRecipe.instructions || '', imageUrl: initialRecipe.imageUrl || '', servings: initialRecipe.servings as number | null, + isPublic: initialRecipe.isPublic, ingredients: initialRecipe.ingredients.map((ing) => ({ productId: ing.productId, quantity: String(ing.quantity), @@ -469,6 +470,23 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:

+ {/* Synlighet */} +
+

Synlighet

+ +

+ Privata recept syns bara för dig och de du delar med. +

+
+ {/* Ingredienser */}

Ingredienser

diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 00000000..185581fc --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL ?? '/api'; + +export default function RegisterPage() { + const router = useRouter(); + const [form, setForm] = useState({ username: '', email: '', password: '', confirm: '' }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + if (form.password !== form.confirm) { + setError('Lösenorden matchar inte'); + return; + } + setLoading(true); + const res = await fetch('/api/auth-register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: form.username, email: form.email, password: form.password }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.message ?? 'Registrering misslyckades'); + setLoading(false); + return; + } + // Auto-login after register + await signIn('credentials', { username: form.username, password: form.password, redirect: false }); + router.push('/'); + router.refresh(); + } + + return ( +
+

Skapa konto

+
+ {(['username', 'email', 'password', 'confirm'] as const).map((field) => ( +
+ + setForm((f) => ({ ...f, [field]: e.target.value }))} + required + minLength={field.includes('password') || field === 'confirm' ? 8 : undefined} + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid #ccc', fontSize: '1rem' }} + /> +
+ ))} + {error &&

{error}

} + +

+ Har du redan ett konto? Logga in +

+
+
+ ); +} diff --git a/frontend/auth.ts b/frontend/auth.ts new file mode 100644 index 00000000..5068beb6 --- /dev/null +++ b/frontend/auth.ts @@ -0,0 +1,57 @@ +import NextAuth from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://recipe-api:3000/api'; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + providers: [ + Credentials({ + credentials: { + username: { label: 'Användarnamn', type: 'text' }, + password: { label: 'Lösenord', type: 'password' }, + }, + async authorize(credentials) { + if (!credentials?.username || !credentials?.password) return null; + try { + const res = await fetch(`${BACKEND_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: credentials.username, + password: credentials.password, + }), + }); + if (!res.ok) return null; + const data = await res.json() as { accessToken: string; userId: number; username: string }; + return { + id: String(data.userId), + name: data.username, + accessToken: data.accessToken, + }; + } catch { + return null; + } + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.accessToken = (user as any).accessToken as string; + token.userId = Number(user.id); + token.username = user.name ?? ''; + } + return token; + }, + session({ session, token }) { + session.accessToken = token.accessToken as string; + session.user.id = String(token.userId); + session.user.name = token.username as string; + return session; + }, + }, + pages: { + signIn: '/login', + }, + session: { strategy: 'jwt' }, +}); diff --git a/frontend/features/inventory/types.ts b/frontend/features/inventory/types.ts index 3f3abcde..96734660 100644 --- a/frontend/features/inventory/types.ts +++ b/frontend/features/inventory/types.ts @@ -101,6 +101,10 @@ export type Recipe = { instructions: string | null; imageUrl: string | null; servings: number | null; + isPublic: boolean; + ownerId: number | null; + owner: { id: number; username: string } | null; + shares: { userId: number }[]; createdAt: string; updatedAt: string; ingredients: RecipeIngredient[]; diff --git a/frontend/lib/auth-headers.ts b/frontend/lib/auth-headers.ts new file mode 100644 index 00000000..e5318fa5 --- /dev/null +++ b/frontend/lib/auth-headers.ts @@ -0,0 +1,11 @@ +import { auth } from '../../auth'; + +/** + * Returnerar Authorization-header med JWT från sessionen. + * Används i alla server-side API-proxy-routes. + */ +export async function getAuthHeaders(): Promise> { + const session = await auth(); + if (!session?.accessToken) return {}; + return { Authorization: `Bearer ${session.accessToken}` }; +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..b72b023c --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { auth } from './auth'; + +export default auth((req) => { + const { pathname } = req.nextUrl; + + // Alltid tillgängliga sidor + const publicPaths = ['/login', '/register']; + if (publicPaths.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // Om ej inloggad, omdirigera till /login + if (!req.auth) { + const loginUrl = new URL('/login', req.url); + loginUrl.searchParams.set('callbackUrl', pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +}); + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'], +}; diff --git a/frontend/package.json b/frontend/package.json index 4799efc9..8f968709 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "next": "16.2", + "next-auth": "^5.0.0-beta.28", "react": "19.2", "react-dom": "19.2" }, diff --git a/frontend/types/next-auth.d.ts b/frontend/types/next-auth.d.ts new file mode 100644 index 00000000..33bc4bdb --- /dev/null +++ b/frontend/types/next-auth.d.ts @@ -0,0 +1,11 @@ +import type { DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + interface Session { + accessToken: string; + user: { + id: string; + name: string; + } & DefaultSession['user']; + } +}