diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index ebba3050..f7737cc2 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -33,6 +33,7 @@ | Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic | | Användarroller (user / admin) | ✅ Klart | | Användarhantering i admin-UI | ✅ Klart | +| Profilsida med flikar (Min profil / Användare / Databas) | ✅ Klart | | Teknisk skuld — oanvända InventoryItem-fält | ✅ Klart (migration 20260418) | | Teknisk skuld — redirect-routes städade | ✅ Klart | | Avancerad AI-integration (veckoplanering, kampanjdata) | ❌ Planerad | @@ -51,24 +52,33 @@ - Generera en ny `002-seed-products.sql` med korrekt `categoryId` per rad (via `SELECT` mot live-db) - Ta bort `.disabled`-suffixet och testa fresh install -### 2. Användarroller ✅ +### 2. Användarroller och full användarhantering ✅ **Klart.** -Systemet har nu fullständig rollbaserad åtkomstkontroll med två roller: `user` (standard) och `admin`. - -**Vad som implementerades:** +Systemet har nu fullständig rollbaserad åtkomstkontroll och ett komplett användarhanteringsgränssnitt inbyggt i profilsidan. +**Rollsystemet:** - **Prisma-migration** (`20260418100000_add_user_role`) — fältet `role String @default("user")` lades till på `User`-modellen - **`@Roles('admin')`-dekoratorn** (`auth/decorators/roles.decorator.ts`) — använder `SetMetadata` för att markera endpoints - **`RolesGuard`** (`auth/roles.guard.ts`) — registrerad globalt som `APP_GUARD`; läser rollmetadata, kastar 403 om rätt roll saknas - **JWT inkluderar nu `role`** — `jwt.strategy.ts` returnerar `{userId, username, role}`, `auth.service.ts` signerar med `role` i payload - **Bootstrap-användare** (`users/admin-bootstrap.service.ts`) — `OnApplicationBootstrap` skapar/uppdaterar Nadmin, Padmin, user1 och user2 vid varje uppstart via miljövariabler - **Skyddade produkt-endpoints** — `@Roles('admin')` på `merge`, `delete`, `restore`, `reset-all`, `bulk-update`, `backfill-canonical` i `products.controller.ts` -- **Användarhantering-API** — `GET /api/users` och `PATCH /api/users/:id/role` (båda kräver admin-roll) -- **Frontend-session** — `auth.ts` sparar `role` i JWT och session; `types/next-auth.d.ts` utgör typdefinition -- **Proxy-skydd** — `proxy.ts` blockerar `/admin/*` för användare utan admin-roll -- **Admin-UI** — `/admin/users` med tabell och rollbyteskäppar; länken "👥 Användare" i navigeringen visas enbart för admins -- **API-proxies** — `app/api/admin-users/route.ts` och `app/api/admin-users/[id]/route.ts` vidarebefordrar anrop med auth-header + +**Backend-endpoints för användarhantering (alla kräver admin-roll):** +- `GET /api/users` — lista alla användare +- `PATCH /api/users/:id/role` — ändra roll +- `POST /api/users` — skapa ny användare (validering: unikt användarnamn och e-post) +- `DELETE /api/users/:id` — ta bort användare (skyddad: kan inte ta bort sig själv) +- `POST /api/users/:id/reset-password` — genererar tillfälligt lösenord, returnerar meddelandetext + lösenord +- `PATCH /api/users/:id/email` — uppdatera e-postadress + +**Profilsidan med flikar (`/profil`):** +- `?tab=profil` — Min profil (alla användare) +- `?tab=anvandare` — Användare (enbart admin): skapa, ta bort, rollbyte, e-postbyte, lösenordsåterställning med kopierings-modal +- `?tab=databas` — Databas (enbart admin): produktadmin (samma innehåll som `/admin/products`) +- `/admin/users` omdirigerar till `/profil?tab=anvandare` +- Navigeringslänken "👥 Användare" går direkt till `/profil?tab=anvandare` ### 3. Matplan-vy (frontend-polish) ✅ **Klart.** diff --git a/README.md b/README.md index ce7d4ae7..df4a95a4 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,27 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på > Obs: Destruktiva åtgärder (merge, ta bort, återställ, bulk-uppdatera, återställ all data) kräver admin-roll. -### Admin: Användare -- **Lista alla användare** — se användarnamn, e-postadress, namn, roll och registreringsdatum -- **Ändra roll** — växla en användares roll mellan `user` och `admin` med ett klick -- **Skyddad sida** — `/admin/users` är enbart åtkomlig för inloggade användare med admin-roll; övriga omdirigeras till startsidan -- **Navigering** — länken "👥 Användare" visas bara i huvudmenyn om inloggad användare har admin-roll +### Användarprofil och administration (fliksida) +Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar beror på rollen: + +**Alla inloggade användare:** +- **Min profil** — redigera förnamn, efternamn och e-postadress + +**Enbart admin:** +- **Användare** — fullständig användarhantering: + - Skapa ny användare (användarnamn, e-post, lösenord, roll) + - Ändra roll via dropdown direkt i tabellen + - Ändra e-postadress inline + - Återställ lösenord — genererar ett tillfälligt lösenord och visar ett kopierings-redo meddelande + - Ta bort användare (skyddad: kan inte ta bort sig själv) +- **Databas** — produktdatabasen: redigera, merga, bulk-kategorisera och återställa produkter ### Autentisering och roller - **Rollbaserad åtkomstkontroll** — systemet har två roller: `user` (standard) och `admin` - **Automatisk bootstrap** — fyra användare skapas eller uppdateras automatiskt när backend startar, baserat på miljövariabler: - `Nadmin` (admin), `Padmin` (admin), `user1` (user), `user2` (user) -- **Skyddade admin-endpoints** — destruktiva produkt-endpoints och användarhantering kräver `admin`-roll; försök utan rätt roll ger 403 Förbjuden - -### Användarprofil -- **Redigera profilinformation** — uppdatera förnamn, efternamn och e-postadress under "Min profil" +- **Skyddade admin-endpoints** — destruktiva produkt-endpoints och all användarhantering kräver `admin`-roll; försök utan rätt roll ger 403 Förbjuden +- **Navigering** — admin-länkarna "👥 Användare" och admin-flikarna i profilen visas enbart för inloggade administratörer --- diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index 94fadbd0..b883ef61 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -67,8 +67,12 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" | **Hem** | `app/page.tsx` | Startsida | | **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil | | **Inloggning** | `app/login/page.tsx` | Inloggningssida med Auth.js Credentials | -| **Profil** | `app/profil/page.tsx` | Redigera firstName, lastName, email | -| | `app/profil/ProfileClient.tsx` | Klientkomponent för profilformulär | +| **Profil** | `app/profil/page.tsx` | Flikbaserad profil-/adminsida (server component): läser `?tab=`-param, kontrollerar admin-roll via `auth()`, laddar rätt flik dynamiskt | +| | `app/profil/ProfileTabs.tsx` | Klientkomponent: fliknavigering med Link-basad URL-routing (`?tab=profil\|anvandare\|databas`) | +| | `app/profil/tabs/MinProfilTab.tsx` | Profilformulär (förnamn, efternamn, e-post) | +| | `app/profil/tabs/AnvandareTab.tsx` | Server component: hämtar användarlista, renderar AnvandareClient | +| | `app/profil/tabs/AnvandareClient.tsx` | Klientkomponent: skapa/ta bort användare, rollbyte, e-postbyte, lösenordsåterställning med kopierings-modal | +| | `app/profil/tabs/DatabsTab.tsx` | Server component: produktdatabas (importerar admin/products-komponenter) | | **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor | | | `InventoryList.tsx` | Ritning av inventarieföremål | | | `InventoryForm.tsx` | Skapa nytt inventarieföremål | @@ -89,8 +93,7 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" | | `app/matplan/MealPlanClient.tsx` | Veckovy, receptval per dag, portionsjustering, inköpslista, inventariejämförelse | | **Kvittoimport** | `app/import/page.tsx` | Server component med Navigation + flikvy | | | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar | -| **Admin: Användare** | `app/admin/users/page.tsx` | Server component: hämtar alla användare, kräver admin-roll | -| | `app/admin/users/UserAdminClient.tsx` | Klientkomponent: tabell med rollbyte-knappar, anropar `/api/admin-users/:id` | +| **Admin: Användare** | `app/admin/users/page.tsx` | Redirect till `/profil?tab=anvandare` | | **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel | | | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering | | | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar | @@ -108,8 +111,9 @@ Alla proxy-routes läser auth-token via `auth()` (Auth.js v5) och vidarebefordra | Route | Metod | Syfte | |-------|-------|-------| -| `/api/admin-users` | GET | Hämtar alla användare (kräver admin-roll i session) | -| `/api/admin-users/[id]` | PATCH | Ändrar roll för användare med givet id (kräver admin-roll i session) | +| `/api/admin-users` | GET, POST | Hämtar alla användare / skapar ny användare (kräver admin-roll i session) | +| `/api/admin-users/[id]` | PATCH, DELETE, PUT | Ändrar roll / tar bort användare / byter e-post (kräver admin-roll i session) | +| `/api/admin-users/[id]/reset-password` | POST | Återställer lösenord och returnerar tillfälligt lösenord + meddelandetext (kräver admin-roll) | | `/api/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) | | `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) | | `/api/categories` | GET | Kategorihierarki (publik, proxies `/api/categories/tree`) | @@ -173,8 +177,10 @@ backend/src/ ├── users/ │ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me │ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin) +│ │ # POST /api/users (admin), DELETE /api/users/:id (admin) +│ │ # POST /api/users/:id/reset-password (admin), PATCH /api/users/:id/email (admin) │ ├── users.service.ts # findByUsername, findById, create, updateProfile -│ │ # findAll, setRole +│ │ # findAll, setRole, adminCreate, deleteUser, resetPassword, updateEmail │ ├── admin-bootstrap.service.ts # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare │ └── users.module.ts ├── categories/ diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 55848a59..02614a91 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Patch, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common'; -import { IsEmail, IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { Controller, Get, Patch, Post, Delete, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common'; +import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import { UsersService } from './users.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -9,6 +9,29 @@ class SetRoleDto { role: string; } +class AdminCreateUserDto { + @IsString() + @MinLength(2) + @MaxLength(50) + username: string; + + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; + + @IsOptional() + @IsIn(['admin', 'user']) + role?: string; +} + +class UpdateEmailDto { + @IsEmail() + email: string; +} + class UpdateProfileDto { @IsOptional() @IsString() @@ -74,4 +97,55 @@ export class UsersController { const updated = await this.usersService.setRole(id, dto.role); return { id: updated.id, username: updated.username, role: updated.role }; } -} + + @Roles('admin') + @Post() + async adminCreateUser( + @Body() dto: AdminCreateUserDto, + ) { + const user = await this.usersService.adminCreate(dto); + return { id: user.id, username: user.username, email: user.email, role: user.role, createdAt: user.createdAt }; + } + + @Roles('admin') + @Delete(':id') + async deleteUser( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() caller: { userId: number }, + ) { + if (caller.userId === id) throw new BadRequestException('Du kan inte ta bort ditt eget konto'); + await this.usersService.deleteUser(id); + return { deleted: true }; + } + + @Roles('admin') + @Post(':id/reset-password') + async resetPassword( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() caller: { userId: number }, + ) { + if (caller.userId === id) throw new BadRequestException('Du kan inte återställa ditt eget lösenord härifrån'); + const user = await this.usersService.findById(id); + if (!user) throw new BadRequestException('Användaren hittades inte'); + const { temporaryPassword } = await this.usersService.resetPassword(id); + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'appen'; + const displayName = user.firstName ? user.firstName : user.username; + return { + to: user.email, + subject: 'Ditt lösenord har återställts', + body: `Hej ${displayName},\n\nDitt lösenord har återställts av en administratör.\nDitt nya tillôlliga lösenord är: ${temporaryPassword}\n\nLogga in på ${appUrl} och byt lösenord snarast.\n\nHälsningar`, + temporaryPassword, + }; + } + + @Roles('admin') + @Patch(':id/email') + async updateEmail( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() caller: { userId: number }, + @Body() dto: UpdateEmailDto, + ) { + if (caller.userId === id) throw new BadRequestException('Använd "Min profil" för att ändra din egen e-post'); + const updated = await this.usersService.updateEmail(id, dto.email); + return { id: updated.id, username: updated.username, email: updated.email }; + } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 5ca61ca9..5d855ad6 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,5 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, ConflictException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcryptjs'; +import * as crypto from 'crypto'; @Injectable() export class UsersService { @@ -31,4 +33,35 @@ export class UsersService { setRole(id: number, role: string) { return this.prisma.user.update({ where: { id }, data: { role } }); } + + async adminCreate(data: { username: string; email: string; password: string; role?: string }) { + const existing = await this.prisma.user.findFirst({ + where: { OR: [{ username: data.username }, { email: data.email }] }, + }); + if (existing) { + throw new ConflictException( + existing.username === data.username ? 'Användarnamnet är redan taget' : 'E-postadressen används redan', + ); + } + const passwordHash = await bcrypt.hash(data.password, 12); + return this.prisma.user.create({ + data: { username: data.username, email: data.email, passwordHash, role: data.role ?? 'user' }, + }); + } + + deleteUser(id: number) { + return this.prisma.user.delete({ where: { id } }); + } + + async resetPassword(id: number): Promise<{ temporaryPassword: string }> { + // Generera läsbart 12-teckens lösenord (4 ord från slumpmässiga bytes) + const temporaryPassword = crypto.randomBytes(9).toString('base64url').slice(0, 12); + const passwordHash = await bcrypt.hash(temporaryPassword, 12); + await this.prisma.user.update({ where: { id }, data: { passwordHash } }); + return { temporaryPassword }; + } + + updateEmail(id: number, email: string) { + return this.prisma.user.update({ where: { id }, data: { email } }); + } } diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index e4067dcb..c06ac34d 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -35,7 +35,7 @@ export default async function Navigation() { 🏪 Baslager ⚙️ Admin {(session?.user as any)?.role === 'admin' && ( - 👥 Användare + 👥 Användare )} 📥 Importera 📅 Matplan diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx index 45c0524e..7213d49b 100644 --- a/frontend/app/admin/users/page.tsx +++ b/frontend/app/admin/users/page.tsx @@ -1,30 +1,6 @@ -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

- -
- ); +export default function AdminUsersPage() { + redirect('/profil?tab=anvandare'); } + diff --git a/frontend/app/api/admin-users/[id]/reset-password/route.ts b/frontend/app/api/admin-users/[id]/reset-password/route.ts new file mode 100644 index 00000000..e8616736 --- /dev/null +++ b/frontend/app/api/admin-users/[id]/reset-password/route.ts @@ -0,0 +1,23 @@ +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 POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + 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/${id}/reset-password`, { + method: 'POST', + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/frontend/app/api/admin-users/[id]/route.ts b/frontend/app/api/admin-users/[id]/route.ts index 9d797ecc..1372462c 100644 --- a/frontend/app/api/admin-users/[id]/route.ts +++ b/frontend/app/api/admin-users/[id]/route.ts @@ -4,15 +4,19 @@ import { auth } from '../../../../auth'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'; +async function getAdminSession() { + const session = await auth(); + if (!session || (session.user as any)?.role !== 'admin') return null; + return session; +} + export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const session = await auth(); - if (!session || (session.user as any)?.role !== 'admin') { - return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); - } + const session = await getAdminSession(); + if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); const body = await request.json(); const res = await fetch(`${API_BASE}/api/users/${id}/role`, { @@ -26,3 +30,42 @@ export async function PATCH( const data = await res.json(); return NextResponse.json(data, { status: res.status }); } + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const session = await getAdminSession(); + if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); + + const res = await fetch(`${API_BASE}/api/users/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + const data = await res.json().catch(() => ({ deleted: true })); + return NextResponse.json(data, { status: res.status }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + // PUT används för e-postbyte (PATCH /api/users/:id/email) + const { id } = await params; + const session = await getAdminSession(); + if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); + + const body = await request.json(); + const res = await fetch(`${API_BASE}/api/users/${id}/email`, { + 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 index 103b312f..cef021d3 100644 --- a/frontend/app/api/admin-users/route.ts +++ b/frontend/app/api/admin-users/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { auth } from '../../../auth'; const API_BASE = @@ -17,3 +17,22 @@ export async function GET() { const data = await res.json(); return NextResponse.json(data, { status: res.status }); } + +export async function POST(request: NextRequest) { + 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`, { + method: 'POST', + 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/profil/ProfileTabs.tsx b/frontend/app/profil/ProfileTabs.tsx new file mode 100644 index 00000000..b181f8b3 --- /dev/null +++ b/frontend/app/profil/ProfileTabs.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; + +type Tab = { id: string; label: string }; + +const USER_TABS: Tab[] = [{ id: 'profil', label: 'Min profil' }]; +const ADMIN_TABS: Tab[] = [ + { id: 'profil', label: 'Min profil' }, + { id: 'anvandare', label: '👥 Användare' }, + { id: 'databas', label: '🗄️ Databas' }, +]; + +type Props = { + activeTab: string; + isAdmin: boolean; +}; + +export default function ProfileTabs({ activeTab, isAdmin }: Props) { + const tabs = isAdmin ? ADMIN_TABS : USER_TABS; + + return ( +
+ {tabs.map((tab) => { + const active = tab.id === activeTab; + return ( + + {tab.label} + + ); + })} +
+ ); +} diff --git a/frontend/app/profil/page.tsx b/frontend/app/profil/page.tsx index 1df53b9e..384275f1 100644 --- a/frontend/app/profil/page.tsx +++ b/frontend/app/profil/page.tsx @@ -1,16 +1,40 @@ +import { auth } from '../../auth'; import Navigation from '../Navigation'; -import ProfileClient from './ProfileClient'; +import ProfileTabs from './ProfileTabs'; +import MinProfilTab from './tabs/MinProfilTab'; export const metadata = { title: 'Min profil' }; -export default function ProfilPage() { +type Props = { + searchParams: Promise<{ tab?: string }>; +}; + +export default async function ProfilPage({ searchParams }: Props) { + const { tab = 'profil' } = await searchParams; + const session = await auth(); + const isAdmin = (session?.user as any)?.role === 'admin'; + + // DatabsTab och AnvandareTab laddas dynamiskt för att hålla page.tsx tunn + let TabContent: React.ComponentType; + if (tab === 'databas' && isAdmin) { + const { default: DatabsTab } = await import('./tabs/DatabsTab'); + TabContent = DatabsTab; + } else if (tab === 'anvandare' && isAdmin) { + const { default: AnvandareTab } = await import('./tabs/AnvandareTab'); + TabContent = AnvandareTab; + } else { + TabContent = MinProfilTab; + } + return ( <> -
+

Min profil

- + +
); } + diff --git a/frontend/app/profil/tabs/AnvandareClient.tsx b/frontend/app/profil/tabs/AnvandareClient.tsx new file mode 100644 index 00000000..a0051404 --- /dev/null +++ b/frontend/app/profil/tabs/AnvandareClient.tsx @@ -0,0 +1,575 @@ +'use client'; + +import { useState } from 'react'; + +interface User { + id: number; + username: string; + email: string; + role: string; + firstName?: string; + lastName?: string; + createdAt: string; +} + +interface ResetResult { + to: string; + subject: string; + body: string; + temporaryPassword: string; +} + +interface Props { + users: User[]; + currentUserId: number; +} + +export default function AnvandareClient({ users: initial, currentUserId }: Props) { + const [users, setUsers] = useState(initial); + const [error, setError] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [creating, setCreating] = useState(false); + const [createForm, setCreateForm] = useState({ + username: '', + email: '', + password: '', + role: 'user', + }); + const [createError, setCreateError] = useState(''); + + // Lösenordsåterställning modal + const [resetResult, setResetResult] = useState(null); + const [copiedBody, setCopiedBody] = useState(false); + const [copiedPw, setCopiedPw] = useState(false); + + // Inline e-postbyte + const [editingEmailId, setEditingEmailId] = useState(null); + const [editingEmail, setEditingEmail] = useState(''); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setCreateError(''); + try { + const res = await fetch('/api/admin-users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createForm), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Kunde inte skapa användare'); + setUsers((prev) => [...prev, data]); + setShowCreate(false); + setCreateForm({ username: '', email: '', password: '', role: 'user' }); + } catch (err: any) { + setCreateError(err.message); + } finally { + setCreating(false); + } + } + + async function handleDelete(id: number) { + if (!confirm('Är du säker på att du vill ta bort användaren?')) return; + setError(''); + const res = await fetch(`/api/admin-users/${id}`, { method: 'DELETE' }); + if (res.ok) { + setUsers((prev) => prev.filter((u) => u.id !== id)); + } else { + const data = await res.json().catch(() => ({})); + setError(data.message ?? 'Kunde inte ta bort användaren'); + } + } + + async function handleRoleChange(id: number, role: string) { + setError(''); + const res = await fetch(`/api/admin-users/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }); + if (res.ok) { + const updated = await res.json(); + setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, role: updated.role } : u))); + } else { + const data = await res.json().catch(() => ({})); + setError(data.message ?? 'Kunde inte ändra roll'); + } + } + + async function handleResetPassword(id: number) { + setError(''); + const res = await fetch(`/api/admin-users/${id}/reset-password`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) { + setError(data.message ?? 'Kunde inte återställa lösenord'); + return; + } + setResetResult(data); + setCopiedBody(false); + setCopiedPw(false); + } + + async function handleEmailSave(id: number) { + setError(''); + const res = await fetch(`/api/admin-users/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: editingEmail }), + }); + if (res.ok) { + const updated = await res.json(); + setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, email: updated.email } : u))); + setEditingEmailId(null); + } else { + const data = await res.json().catch(() => ({})); + setError(data.message ?? 'Kunde inte uppdatera e-post'); + } + } + + return ( +
+ {/* Lägg till användare */} +
+ + + {showCreate && ( +
+

Ny användare

+ {createError && ( +
+ {createError} +
+ )} + {[ + { key: 'username', label: 'Användarnamn', type: 'text' }, + { key: 'email', label: 'E-post', type: 'email' }, + { key: 'password', label: 'Lösenord', type: 'password' }, + ].map(({ key, label, type }) => ( +
+ + setCreateForm((f) => ({ ...f, [key]: e.target.value }))} + required + style={{ + width: '100%', + padding: '0.4rem 0.6rem', + border: '1px solid #cbd5e1', + borderRadius: 4, + fontSize: 14, + boxSizing: 'border-box', + }} + /> +
+ ))} +
+ + +
+ +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {/* Användartabell */} +
+ + + + {['Användare', 'E-post', 'Roll', 'Åtgärder'].map((h) => ( + + ))} + + + + {users.map((user) => { + const isSelf = user.id === currentUserId; + const isEditingEmail = editingEmailId === user.id; + return ( + + {/* Namn */} + + + {/* E-post */} + + + {/* Roll */} + + + {/* Åtgärder */} + + + ); + })} + +
+ {h} +
+
{user.username}
+ {(user.firstName || user.lastName) && ( +
+ {[user.firstName, user.lastName].filter(Boolean).join(' ')} +
+ )} + {isSelf && ( + + Du själv + + )} +
+ {isEditingEmail ? ( +
+ setEditingEmail(e.target.value)} + style={{ + padding: '0.3rem 0.5rem', + border: '1px solid #93c5fd', + borderRadius: 4, + fontSize: 13, + width: 180, + }} + /> + + +
+ ) : ( +
+ {user.email} + {!isSelf && ( + + )} +
+ )} +
+ {isSelf ? ( + + ) : ( + + )} + + {isSelf ? ( + + ) : ( +
+ + +
+ )} +
+
+ + {/* Lösenordsåterställning modal */} + {resetResult && ( +
+
+

Lösenordet har återställts

+

+ Skicka nedanstående meddelande till användaren och/eller ge dem det tillfälliga lösenordet. +

+ +
+
+ + +
+