diff --git a/.env b/.env index 09e803f0..512652f4 100644 --- a/.env +++ b/.env @@ -10,3 +10,9 @@ MARIADB_PASSWORD=Imminent-Umpire-Undertook8-Crunchy # Använder root för att prisma migrate deploy skall fungera DATABASE_URL=mysql://root:Encrust6-Deserve-Stricken-Spectacle@recipe-db:3306/recipe_app + +# Bootstrap-användare (skapas/uppdateras vid appstart) +ADMIN_NADMIN_PASSWORD=Extra-Bra-Konto1 +ADMIN_PADMIN_PASSWORD=Extra-Bra-Konto2 +SEED_USER1_PASSWORD=Test-Anv1-Fbg +SEED_USER2_PASSWORD=Test-Anv2-FBG diff --git a/.env.example b/.env.example index b5c71978..2f8c5670 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ MARIADB_PASSWORD=byt-ut-mig # Publik URL (används av frontend) NEXT_PUBLIC_APP_URL=https://recept.gynther.se NEXT_PUBLIC_API_URL=https://api.recept.gynther.se + +# Bootstrap-användare (skapas/uppdateras vid appstart) +ADMIN_NADMIN_PASSWORD=byt-ut-mig +ADMIN_PADMIN_PASSWORD=byt-ut-mig +SEED_USER1_PASSWORD=byt-ut-mig +SEED_USER2_PASSWORD=byt-ut-mig diff --git a/backend/prisma/migrations/20260418100000_add_user_role/migration.sql b/backend/prisma/migrations/20260418100000_add_user_role/migration.sql new file mode 100644 index 00000000..644783c9 --- /dev/null +++ b/backend/prisma/migrations/20260418100000_add_user_role/migration.sql @@ -0,0 +1,2 @@ +-- Add role field to User +ALTER TABLE `User` ADD COLUMN `role` VARCHAR(191) NOT NULL DEFAULT 'user'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b4b9af6a..d7ff9a94 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,6 +14,7 @@ model User { firstName String? lastName String? passwordHash String + role String @default("user") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ff671bc9..7a4e107b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { UsersModule } from './users/users.module'; import { UserProductsModule } from './user-products/user-products.module'; import { CategoriesModule } from './categories/categories.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; +import { RolesGuard } from './auth/roles.guard'; @Module({ @@ -39,6 +40,10 @@ import { JwtAuthGuard } from './auth/jwt-auth.guard'; provide: APP_GUARD, useClass: JwtAuthGuard, }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, ], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 4c5bfeb1..460857c5 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -27,7 +27,7 @@ export class AuthService { passwordHash, }); - return this.issueToken(user.id, user.username); + return this.issueToken(user.id, user.username, user.role); } async login(dto: LoginDto) { @@ -37,15 +37,16 @@ export class AuthService { 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); + return this.issueToken(user.id, user.username, user.role); } - private issueToken(userId: number, username: string) { - const payload = { sub: userId, username }; + private issueToken(userId: number, username: string, role: string) { + const payload = { sub: userId, username, role }; return { accessToken: this.jwtService.sign(payload), userId, username, + role, }; } } diff --git a/backend/src/auth/decorators/roles.decorator.ts b/backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 00000000..8df7064a --- /dev/null +++ b/backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index e7011217..7f09cfd4 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: { sub: number; username: string }) { - return { userId: payload.sub, username: payload.username }; + async validate(payload: { sub: number; username: string; role: string }) { + return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user' }; } } diff --git a/backend/src/auth/roles.guard.ts b/backend/src/auth/roles.guard.ts new file mode 100644 index 00000000..129e2c61 --- /dev/null +++ b/backend/src/auth/roles.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from './decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // No @Roles() decorator — allow any authenticated user + if (!requiredRoles || requiredRoles.length === 0) return true; + + const { user } = context.switchToHttp().getRequest(); + if (!user?.role || !requiredRoles.includes(user.role)) { + throw new ForbiddenException('Åtkomst nekad — admin-behörighet krävs'); + } + return true; + } +} diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 9d6b2271..c94d6657 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -19,6 +19,7 @@ import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto'; import { SetTagsDto } from './dto/set-tags.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto'; +import { Roles } from '../auth/decorators/roles.decorator'; @Controller('products') export class ProductsController { @@ -50,6 +51,7 @@ export class ProductsController { return this.productsService.previewMerge(sourceProductId, targetProductId); } + @Roles('admin') @Post('backfill-canonical') backfillCanonical() { return this.productsService.backfillCanonicalNames(); @@ -65,6 +67,7 @@ export class ProductsController { return this.productsService.create(body); } + @Roles('admin') @Post('merge') merge(@Body() body: MergeProductsDto) { return this.productsService.merge(body.sourceProductId, body.targetProductId); @@ -102,22 +105,26 @@ export class ProductsController { return this.productsService.update(id, body); } + @Roles('admin') @Delete(':id') remove(@Param('id', ParseIntPipe) id: number) { return this.productsService.remove(id); } + @Roles('admin') @Post(':id/restore') restore(@Param('id', ParseIntPipe) id: number) { return this.productsService.restore(id); } + @Roles('admin') @Post('reset-all') @HttpCode(200) resetAll() { return this.productsService.resetAll(); } + @Roles('admin') @Post('bulk-update') @HttpCode(200) bulkUpdate(@Body() body: BulkUpdateProductsDto) { diff --git a/backend/src/users/admin-bootstrap.service.ts b/backend/src/users/admin-bootstrap.service.ts new file mode 100644 index 00000000..e970e2e4 --- /dev/null +++ b/backend/src/users/admin-bootstrap.service.ts @@ -0,0 +1,54 @@ +import { Injectable, OnApplicationBootstrap, Logger } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../prisma/prisma.service'; + +type SeedUser = { + username: string; + email: string; + passwordEnvKey: string; + role: string; +}; + +const SEED_USERS: SeedUser[] = [ + { username: 'Nadmin', email: 'nadmin@localhost', passwordEnvKey: 'ADMIN_NADMIN_PASSWORD', role: 'admin' }, + { username: 'Padmin', email: 'padmin@localhost', passwordEnvKey: 'ADMIN_PADMIN_PASSWORD', role: 'admin' }, + { username: 'user1', email: 'user1@localhost', passwordEnvKey: 'SEED_USER1_PASSWORD', role: 'user' }, + { username: 'user2', email: 'user2@localhost', passwordEnvKey: 'SEED_USER2_PASSWORD', role: 'user' }, +]; + +@Injectable() +export class AdminBootstrapService implements OnApplicationBootstrap { + private readonly logger = new Logger(AdminBootstrapService.name); + + constructor(private readonly prisma: PrismaService) {} + + async onApplicationBootstrap() { + for (const seed of SEED_USERS) { + const password = process.env[seed.passwordEnvKey]; + if (!password) { + this.logger.warn(`${seed.passwordEnvKey} not set — skipping ${seed.username}`); + continue; + } + + const existing = await this.prisma.user.findUnique({ where: { username: seed.username } }); + + if (existing) { + if (existing.role !== seed.role) { + await this.prisma.user.update({ where: { id: existing.id }, data: { role: seed.role } }); + this.logger.log(`Updated role for ${seed.username} → ${seed.role}`); + } + } else { + const passwordHash = await bcrypt.hash(password, 12); + await this.prisma.user.create({ + data: { + username: seed.username, + email: seed.email, + passwordHash, + role: seed.role, + }, + }); + this.logger.log(`Created ${seed.role} user: ${seed.username}`); + } + } + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 8e177114..55848a59 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,7 +1,13 @@ -import { Controller, Get, Patch, Body } from '@nestjs/common'; -import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; +import { Controller, Get, Patch, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common'; +import { IsEmail, IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; import { UsersService } from './users.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; + +class SetRoleDto { + @IsIn(['admin', 'user']) + role: string; +} class UpdateProfileDto { @IsOptional() @@ -32,6 +38,7 @@ export class UsersController { email: found?.email, firstName: found?.firstName, lastName: found?.lastName, + role: found?.role, }; } @@ -49,4 +56,22 @@ export class UsersController { lastName: updated.lastName, }; } + + @Roles('admin') + @Get() + listUsers() { + return this.usersService.findAll(); + } + + @Roles('admin') + @Patch(':id/role') + async setRole( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() caller: { userId: number; username: string; role: string }, + @Body() dto: SetRoleDto, + ) { + if (caller.userId === id) throw new BadRequestException('Du kan inte ändra din egen roll'); + const updated = await this.usersService.setRole(id, dto.role); + return { id: updated.id, username: updated.username, role: updated.role }; + } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index a34ebc08..1bab2ffe 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; +import { AdminBootstrapService } from './admin-bootstrap.service'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ imports: [PrismaModule], - providers: [UsersService], + providers: [UsersService, AdminBootstrapService], controllers: [UsersController], exports: [UsersService], }) diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 1debd471..5ca61ca9 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -20,4 +20,15 @@ export class UsersService { updateProfile(id: number, data: { firstName?: string; lastName?: string; email?: string }) { return this.prisma.user.update({ where: { id }, data }); } + + findAll() { + return this.prisma.user.findMany({ + select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, createdAt: true }, + orderBy: { username: 'asc' }, + }); + } + + setRole(id: number, role: string) { + return this.prisma.user.update({ where: { id }, data: { role } }); + } } diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index 00597537..e4067dcb 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -34,6 +34,9 @@ export default async function Navigation() { 📖 Recept 🏪 Baslager ⚙️ Admin + {(session?.user as any)?.role === 'admin' && ( + 👥 Användare + )} 📥 Importera 📅 Matplan diff --git a/frontend/app/admin/users/UserAdminClient.tsx b/frontend/app/admin/users/UserAdminClient.tsx new file mode 100644 index 00000000..2c4e7176 --- /dev/null +++ b/frontend/app/admin/users/UserAdminClient.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState } from 'react'; + +type User = { + id: number; + username: string; + email: string; + firstName: string | null; + lastName: string | null; + role: string; + createdAt: string; +}; + +type Props = { + users: User[]; + currentUserId: string; +}; + +export default function UserAdminClient({ users: initial, currentUserId }: Props) { + const [users, setUsers] = useState(initial); + const [loading, setLoading] = useState(null); + const [error, setError] = useState(null); + + async function toggleRole(user: User) { + const newRole = user.role === 'admin' ? 'user' : 'admin'; + setLoading(user.id); + setError(null); + try { + const res = await fetch(`/api/admin-users/${user.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? 'Okänt fel'); + } + const updated: User = await res.json(); + setUsers((prev) => prev.map((u) => (u.id === updated.id ? { ...u, role: updated.role } : u))); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(null); + } + } + + return ( + <> + {error && ( +
{error}
+ )} + + + + + + + + + + + + + {users.map((user) => { + const isSelf = String(user.id) === currentUserId; + return ( + + + + + + + + + ); + })} + +
AnvändarnamnE-postNamnRollSkapadÅtgärd
{user.username}{user.email} + {[user.firstName, user.lastName].filter(Boolean).join(' ') || '—'} + + + {user.role} + + + {new Date(user.createdAt).toLocaleDateString('sv-SE')} + + {isSelf ? ( + Du själv + ) : ( + + )} +
+ + ); +} diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx new file mode 100644 index 00000000..1e77a4ac --- /dev/null +++ b/frontend/app/admin/users/page.tsx @@ -0,0 +1,30 @@ +import { auth } from '../../../../auth'; +import { redirect } from 'next/navigation'; +import { fetchJson } from '../../../lib/api'; +import UserAdminClient from './UserAdminClient'; + +type User = { + id: number; + username: string; + email: string; + firstName: string | null; + lastName: string | null; + role: string; + createdAt: string; +}; + +export default async function AdminUsersPage() { + const session = await auth(); + if (!session || (session.user as any)?.role !== 'admin') { + redirect('/'); + } + + const users = await fetchJson('/api/users'); + + return ( +
+

Användarhantering

+ +
+ ); +} diff --git a/frontend/app/api/admin-users/[id]/route.ts b/frontend/app/api/admin-users/[id]/route.ts new file mode 100644 index 00000000..741d3204 --- /dev/null +++ b/frontend/app/api/admin-users/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '../../../../../auth'; + +const API_BASE = + process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'; + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } }, +) { + const session = await auth(); + if (!session || (session.user as any)?.role !== 'admin') { + return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); + } + + const body = await request.json(); + const res = await fetch(`${API_BASE}/api/users/${params.id}/role`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/frontend/app/api/admin-users/route.ts b/frontend/app/api/admin-users/route.ts new file mode 100644 index 00000000..07c95d71 --- /dev/null +++ b/frontend/app/api/admin-users/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { auth } from '../../../../auth'; + +const API_BASE = + process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'; + +export async function GET() { + const session = await auth(); + if (!session || (session.user as any)?.role !== 'admin') { + return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); + } + + const res = await fetch(`${API_BASE}/api/users`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + cache: 'no-store', + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/frontend/auth.ts b/frontend/auth.ts index 7fe88182..96356238 100644 --- a/frontend/auth.ts +++ b/frontend/auth.ts @@ -22,11 +22,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }), }); if (!res.ok) return null; - const data = await res.json() as { accessToken: string; userId: number; username: string }; + const data = await res.json() as { accessToken: string; userId: number; username: string; role: string }; return { id: String(data.userId), name: data.username, accessToken: data.accessToken, + role: data.role, }; } catch { return null; @@ -40,6 +41,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ token.accessToken = (user as any).accessToken as string; token.userId = Number(user.id); token.username = user.name ?? ''; + token.role = (user as any).role as string; } return token; }, @@ -47,6 +49,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ session.accessToken = token.accessToken as string; session.user.id = String(token.userId); session.user.name = token.username as string; + (session.user as any).role = token.role as string; return session; }, }, diff --git a/frontend/middleware.ts b/frontend/middleware.ts index b72b023c..c5ca6fa4 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -17,6 +17,14 @@ export default auth((req) => { return NextResponse.redirect(loginUrl); } + // Admin-sidor kräver admin-roll + if (pathname.startsWith('/admin')) { + const role = (req.auth.user as any)?.role; + if (role !== 'admin') { + return NextResponse.redirect(new URL('/', req.url)); + } + } + return NextResponse.next(); }); diff --git a/frontend/types/next-auth.d.ts b/frontend/types/next-auth.d.ts index 33bc4bdb..7d66a364 100644 --- a/frontend/types/next-auth.d.ts +++ b/frontend/types/next-auth.d.ts @@ -6,6 +6,7 @@ declare module 'next-auth' { user: { id: string; name: string; + role: string; } & DefaultSession['user']; } }