feat: Implement admin user management features

- Added adminCreateUser endpoint and corresponding DTO for creating users.
- Implemented deleteUser and resetPassword functionalities for admin users.
- Introduced updateEmail functionality for admin users.
- Updated UsersService to handle user creation, deletion, password reset, and email updates.
- Modified UsersController to include new admin routes with appropriate role checks.
- Refactored frontend navigation to link to user management under profile.
- Created new profile tabs for user management and database management.
- Developed AnvandareClient component for user management, including user creation, deletion, role changes, and password resets.
- Added DatabsTab for managing product listings and merging duplicates.
- Enhanced MinProfilTab for user profile management with form handling.
This commit is contained in:
Nils-Johan Gynther
2026-04-18 14:49:02 +02:00
parent 00dc0d6c69
commit 537a4f8ab6
16 changed files with 1141 additions and 66 deletions
+19 -9
View File
@@ -33,6 +33,7 @@
| Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic | | Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic |
| Användarroller (user / admin) | ✅ Klart | | Användarroller (user / admin) | ✅ Klart |
| Användarhantering i admin-UI | ✅ 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 — oanvända InventoryItem-fält | ✅ Klart (migration 20260418) |
| Teknisk skuld — redirect-routes städade | ✅ Klart | | Teknisk skuld — redirect-routes städade | ✅ Klart |
| Avancerad AI-integration (veckoplanering, kampanjdata) | ❌ Planerad | | 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) - 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 - Ta bort `.disabled`-suffixet och testa fresh install
### 2. Användarroller ✅ ### 2. Användarroller och full användarhantering
**Klart.** **Klart.**
Systemet har nu fullständig rollbaserad åtkomstkontroll med två roller: `user` (standard) och `admin`. Systemet har nu fullständig rollbaserad åtkomstkontroll och ett komplett användarhanteringsgränssnitt inbyggt i profilsidan.
**Vad som implementerades:**
**Rollsystemet:**
- **Prisma-migration** (`20260418100000_add_user_role`) — fältet `role String @default("user")` lades till på `User`-modellen - **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 - **`@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 - **`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 - **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 - **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')``merge`, `delete`, `restore`, `reset-all`, `bulk-update`, `backfill-canonical` i `products.controller.ts` - **Skyddade produkt-endpoints** — `@Roles('admin')``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 **Backend-endpoints för användarhantering (alla kräver admin-roll):**
- **Proxy-skydd** — `proxy.ts` blockerar `/admin/*` för användare utan admin-roll - `GET /api/users` — lista alla användare
- **Admin-UI** — `/admin/users` med tabell och rollbyteskäppar; länken "👥 Användare" i navigeringen visas enbart för admins - `PATCH /api/users/:id/role` — ändra roll
- **API-proxies** — `app/api/admin-users/route.ts` och `app/api/admin-users/[id]/route.ts` vidarebefordrar anrop med auth-header - `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) ✅ ### 3. Matplan-vy (frontend-polish) ✅
**Klart.** **Klart.**
+16 -9
View File
@@ -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. > Obs: Destruktiva åtgärder (merge, ta bort, återställ, bulk-uppdatera, återställ all data) kräver admin-roll.
### Admin: Användare ### Användarprofil och administration (fliksida)
- **Lista alla användare** — se användarnamn, e-postadress, namn, roll och registreringsdatum Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar beror på rollen:
- **Ä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 **Alla inloggade användare:**
- **Navigering** — länken "👥 Användare" visas bara i huvudmenyn om inloggad användare har admin-roll - **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 ### Autentisering och roller
- **Rollbaserad åtkomstkontroll** — systemet har två roller: `user` (standard) och `admin` - **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: - **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) - `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 - **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
### Användarprofil
- **Redigera profilinformation** — uppdatera förnamn, efternamn och e-postadress under "Min profil"
--- ---
+13 -7
View File
@@ -67,8 +67,12 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
| **Hem** | `app/page.tsx` | Startsida | | **Hem** | `app/page.tsx` | Startsida |
| **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil | | **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil |
| **Inloggning** | `app/login/page.tsx` | Inloggningssida med Auth.js Credentials | | **Inloggning** | `app/login/page.tsx` | Inloggningssida med Auth.js Credentials |
| **Profil** | `app/profil/page.tsx` | Redigera firstName, lastName, email | | **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/ProfileClient.tsx` | Klientkomponent för profilformulär | | | `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 | | **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor |
| | `InventoryList.tsx` | Ritning av inventarieföremål | | | `InventoryList.tsx` | Ritning av inventarieföremål |
| | `InventoryForm.tsx` | Skapa nytt 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 | | | `app/matplan/MealPlanClient.tsx` | Veckovy, receptval per dag, portionsjustering, inköpslista, inventariejämförelse |
| **Kvittoimport** | `app/import/page.tsx` | Server component med Navigation + flikvy | | **Kvittoimport** | `app/import/page.tsx` | Server component med Navigation + flikvy |
| | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar | | | `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 | | **Admin: Användare** | `app/admin/users/page.tsx` | Redirect till `/profil?tab=anvandare` |
| | `app/admin/users/UserAdminClient.tsx` | Klientkomponent: tabell med rollbyte-knappar, anropar `/api/admin-users/:id` |
| **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel | | **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel |
| | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering | | | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering |
| | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar | | | `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 | | Route | Metod | Syfte |
|-------|-------|-------| |-------|-------|-------|
| `/api/admin-users` | GET | Hämtar alla användare (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 | Ändrar roll för användare med givet id (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/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) |
| `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) | | `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) |
| `/api/categories` | GET | Kategorihierarki (publik, proxies `/api/categories/tree`) | | `/api/categories` | GET | Kategorihierarki (publik, proxies `/api/categories/tree`) |
@@ -173,8 +177,10 @@ backend/src/
├── users/ ├── users/
│ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me │ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me
│ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin) │ │ # 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 │ ├── 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 │ ├── admin-bootstrap.service.ts # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare
│ └── users.module.ts │ └── users.module.ts
├── categories/ ├── categories/
+76 -2
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Patch, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common'; import { Controller, Get, Patch, Post, Delete, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common';
import { IsEmail, IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
@@ -9,6 +9,29 @@ class SetRoleDto {
role: string; 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 { class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -74,4 +97,55 @@ export class UsersController {
const updated = await this.usersService.setRole(id, dto.role); const updated = await this.usersService.setRole(id, dto.role);
return { id: updated.id, username: updated.username, role: updated.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 };
} }
+34 -1
View File
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -31,4 +33,35 @@ export class UsersService {
setRole(id: number, role: string) { setRole(id: number, role: string) {
return this.prisma.user.update({ where: { id }, data: { role } }); 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 } });
}
} }
+1 -1
View File
@@ -35,7 +35,7 @@ export default async function Navigation() {
<Link href="/baslager" style={linkStyle}>🏪 Baslager</Link> <Link href="/baslager" style={linkStyle}>🏪 Baslager</Link>
<Link href="/admin/products" style={linkStyle}> Admin</Link> <Link href="/admin/products" style={linkStyle}> Admin</Link>
{(session?.user as any)?.role === 'admin' && ( {(session?.user as any)?.role === 'admin' && (
<Link href="/admin/users" style={linkStyle}>👥 Användare</Link> <Link href="/profil?tab=anvandare" style={linkStyle}>👥 Användare</Link>
)} )}
<Link href="/import" style={linkStyle}>📥 Importera</Link> <Link href="/import" style={linkStyle}>📥 Importera</Link>
<Link href="/matplan" style={linkStyle}>📅 Matplan</Link> <Link href="/matplan" style={linkStyle}>📅 Matplan</Link>
+2 -26
View File
@@ -1,30 +1,6 @@
import { auth } from '../../../auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { fetchJson } from '../../../lib/api';
import UserAdminClient from './UserAdminClient';
type User = { export default function AdminUsersPage() {
id: number; redirect('/profil?tab=anvandare');
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<User[]>('/api/users');
return (
<main className="p-6 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Användarhantering</h1>
<UserAdminClient users={users} currentUserId={session.user.id} />
</main>
);
}
@@ -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 });
}
+47 -4
View File
@@ -4,15 +4,19 @@ import { auth } from '../../../../auth';
const API_BASE = const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'; 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( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params; const { id } = await params;
const session = await auth(); const session = await getAdminSession();
if (!session || (session.user as any)?.role !== 'admin') { if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
}
const body = await request.json(); const body = await request.json();
const res = await fetch(`${API_BASE}/api/users/${id}/role`, { const res = await fetch(`${API_BASE}/api/users/${id}/role`, {
@@ -26,3 +30,42 @@ export async function PATCH(
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); 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 });
}
+20 -1
View File
@@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { auth } from '../../../auth'; import { auth } from '../../../auth';
const API_BASE = const API_BASE =
@@ -17,3 +17,22 @@ export async function GET() {
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); 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 });
}
+56
View File
@@ -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 (
<div
style={{
display: 'flex',
gap: '0.25rem',
borderBottom: '2px solid #e5e7eb',
marginBottom: '2rem',
}}
>
{tabs.map((tab) => {
const active = tab.id === activeTab;
return (
<Link
key={tab.id}
href={`/profil?tab=${tab.id}`}
style={{
padding: '0.6rem 1.2rem',
textDecoration: 'none',
fontWeight: active ? 600 : 400,
fontSize: '0.95rem',
color: active ? '#2563eb' : '#555',
borderBottom: active ? '2px solid #2563eb' : '2px solid transparent',
marginBottom: '-2px',
borderRadius: '4px 4px 0 0',
background: active ? '#eff6ff' : 'transparent',
transition: 'background 0.15s',
}}
>
{tab.label}
</Link>
);
})}
</div>
);
}
+28 -4
View File
@@ -1,16 +1,40 @@
import { auth } from '../../auth';
import Navigation from '../Navigation'; import Navigation from '../Navigation';
import ProfileClient from './ProfileClient'; import ProfileTabs from './ProfileTabs';
import MinProfilTab from './tabs/MinProfilTab';
export const metadata = { title: 'Min profil' }; 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 ( return (
<> <>
<Navigation /> <Navigation />
<main style={{ padding: '1rem', maxWidth: '800px', margin: '0 auto' }}> <main style={{ padding: '1rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Min profil</h1> <h1 style={{ marginBottom: '1.5rem' }}>Min profil</h1>
<ProfileClient /> <ProfileTabs activeTab={tab === 'databas' || tab === 'anvandare' ? tab : 'profil'} isAdmin={isAdmin} />
<TabContent />
</main> </main>
</> </>
); );
} }
@@ -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<User[]>(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<ResetResult | null>(null);
const [copiedBody, setCopiedBody] = useState(false);
const [copiedPw, setCopiedPw] = useState(false);
// Inline e-postbyte
const [editingEmailId, setEditingEmailId] = useState<number | null>(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 (
<div>
{/* Lägg till användare */}
<div style={{ marginBottom: '1.5rem' }}>
<button
onClick={() => setShowCreate((v) => !v)}
style={{
background: '#2563eb',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '0.5rem 1.2rem',
cursor: 'pointer',
fontWeight: 500,
}}
>
{showCreate ? ' Stäng' : '+ Lägg till användare'}
</button>
{showCreate && (
<form
onSubmit={handleCreate}
style={{
marginTop: '1rem',
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '1rem 1.5rem',
maxWidth: 420,
}}
>
<h3 style={{ marginTop: 0, marginBottom: '1rem' }}>Ny användare</h3>
{createError && (
<div style={{ color: '#dc2626', marginBottom: '0.75rem', fontSize: 14 }}>
{createError}
</div>
)}
{[
{ 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 }) => (
<div key={key} style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>{label}</label>
<input
type={type}
value={(createForm as any)[key]}
onChange={(e) => 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',
}}
/>
</div>
))}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>Roll</label>
<select
value={createForm.role}
onChange={(e) => setCreateForm((f) => ({ ...f, role: e.target.value }))}
style={{
padding: '0.4rem 0.6rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 14,
}}
>
<option value="user">Användare</option>
<option value="admin">Admin</option>
</select>
</div>
<button
type="submit"
disabled={creating}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '0.5rem 1.2rem',
cursor: 'pointer',
}}
>
{creating ? 'Skapar...' : 'Skapa'}
</button>
</form>
)}
</div>
{error && (
<div
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 6,
padding: '0.6rem 1rem',
color: '#dc2626',
marginBottom: '1rem',
fontSize: 14,
}}
>
{error}
</div>
)}
{/* Användartabell */}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
{['Användare', 'E-post', 'Roll', 'Åtgärder'].map((h) => (
<th
key={h}
style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => {
const isSelf = user.id === currentUserId;
const isEditingEmail = editingEmailId === user.id;
return (
<tr
key={user.id}
style={{ borderBottom: '1px solid #e2e8f0', background: isSelf ? '#f0f9ff' : 'transparent' }}
>
{/* Namn */}
<td style={{ padding: '0.6rem 0.8rem' }}>
<div style={{ fontWeight: 500 }}>{user.username}</div>
{(user.firstName || user.lastName) && (
<div style={{ color: '#64748b', fontSize: 12 }}>
{[user.firstName, user.lastName].filter(Boolean).join(' ')}
</div>
)}
{isSelf && (
<span style={{ fontSize: 11, color: '#2563eb', fontStyle: 'italic' }}>
Du själv
</span>
)}
</td>
{/* E-post */}
<td style={{ padding: '0.6rem 0.8rem' }}>
{isEditingEmail ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
type="email"
value={editingEmail}
onChange={(e) => setEditingEmail(e.target.value)}
style={{
padding: '0.3rem 0.5rem',
border: '1px solid #93c5fd',
borderRadius: 4,
fontSize: 13,
width: 180,
}}
/>
<button
onClick={() => handleEmailSave(user.id)}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '0.2rem 0.5rem',
cursor: 'pointer',
fontSize: 12,
}}
>
Spara
</button>
<button
onClick={() => setEditingEmailId(null)}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: 4,
padding: '0.2rem 0.5rem',
cursor: 'pointer',
fontSize: 12,
}}
>
Avbryt
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#334155' }}>{user.email}</span>
{!isSelf && (
<button
onClick={() => {
setEditingEmailId(user.id);
setEditingEmail(user.email);
}}
title="Ändra e-post"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#64748b',
fontSize: 13,
padding: 0,
}}
>
</button>
)}
</div>
)}
</td>
{/* Roll */}
<td style={{ padding: '0.6rem 0.8rem' }}>
{isSelf ? (
<RoleBadge role={user.role} />
) : (
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
style={{
padding: '0.25rem 0.4rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 13,
background: user.role === 'admin' ? '#eff6ff' : '#f8fafc',
}}
>
<option value="user">Användare</option>
<option value="admin">Admin</option>
</select>
)}
</td>
{/* Åtgärder */}
<td style={{ padding: '0.6rem 0.8rem' }}>
{isSelf ? (
<span style={{ color: '#94a3b8', fontSize: 12 }}></span>
) : (
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleResetPassword(user.id)}
title="Återställ lösenord"
style={{
background: '#fef3c7',
border: '1px solid #fcd34d',
borderRadius: 4,
padding: '0.3rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: '#92400e',
}}
>
Återställ lösenord
</button>
<button
onClick={() => handleDelete(user.id)}
title="Ta bort"
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 4,
padding: '0.3rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: '#dc2626',
}}
>
Ta bort
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Lösenordsåterställning modal */}
{resetResult && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
style={{
background: '#fff',
borderRadius: 10,
padding: '1.5rem 2rem',
maxWidth: 520,
width: '90%',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
}}
>
<h3 style={{ marginTop: 0 }}>Lösenordet har återställts</h3>
<p style={{ fontSize: 13, color: '#475569' }}>
Skicka nedanstående meddelande till användaren och/eller ge dem det tillfälliga lösenordet.
</p>
<div style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<label style={{ fontSize: 13, fontWeight: 500 }}>Meddelande att skicka till användaren</label>
<button
onClick={() => {
navigator.clipboard.writeText(resetResult.body);
setCopiedBody(true);
}}
style={{
background: copiedBody ? '#dcfce7' : '#f1f5f9',
border: '1px solid #cbd5e1',
borderRadius: 4,
padding: '0.2rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: copiedBody ? '#16a34a' : '#334155',
}}
>
{copiedBody ? '✓ Kopierat' : 'Kopiera meddelande'}
</button>
</div>
<textarea
readOnly
value={resetResult.body}
rows={6}
style={{
width: '100%',
padding: '0.5rem',
fontSize: 13,
fontFamily: 'monospace',
border: '1px solid #e2e8f0',
borderRadius: 6,
background: '#f8fafc',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<label style={{ fontSize: 13, fontWeight: 500 }}>Tillfälligt lösenord</label>
<button
onClick={() => {
navigator.clipboard.writeText(resetResult.temporaryPassword);
setCopiedPw(true);
}}
style={{
background: copiedPw ? '#dcfce7' : '#f1f5f9',
border: '1px solid #cbd5e1',
borderRadius: 4,
padding: '0.2rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: copiedPw ? '#16a34a' : '#334155',
}}
>
{copiedPw ? '✓ Kopierat' : 'Kopiera lösenord'}
</button>
</div>
<div
style={{
fontFamily: 'monospace',
fontSize: 18,
fontWeight: 700,
letterSpacing: 2,
padding: '0.5rem 1rem',
background: '#fff7ed',
border: '1px solid #fed7aa',
borderRadius: 6,
color: '#9a3412',
userSelect: 'all',
}}
>
{resetResult.temporaryPassword}
</div>
</div>
{!copiedBody && !copiedPw && (
<div
style={{
background: '#fefce8',
border: '1px solid #fde68a',
borderRadius: 6,
padding: '0.5rem 0.75rem',
fontSize: 12,
color: '#92400e',
marginBottom: '1rem',
}}
>
Kopiera lösenordet eller meddelandet innan du stänger det visas inte igen.
</div>
)}
<button
onClick={() => setResetResult(null)}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: 6,
padding: '0.5rem 1.2rem',
cursor: 'pointer',
fontWeight: 500,
}}
>
Stäng
</button>
</div>
</div>
)}
</div>
);
}
function RoleBadge({ role }: { role: string }) {
return (
<span
style={{
display: 'inline-block',
padding: '0.2rem 0.6rem',
borderRadius: 12,
fontSize: 12,
fontWeight: 600,
background: role === 'admin' ? '#eff6ff' : '#f0fdf4',
color: role === 'admin' ? '#1d4ed8' : '#15803d',
border: `1px solid ${role === 'admin' ? '#bfdbfe' : '#bbf7d0'}`,
}}
>
{role === 'admin' ? 'Admin' : 'Användare'}
</span>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { auth } from '../../../auth';
import AnvandareClient from './AnvandareClient';
export default async function AnvandareTab() {
const session = await auth();
const userId = (session?.user as any)?.userId as number | undefined;
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'}/api/users`,
{
headers: { Authorization: `Bearer ${(session as any)?.accessToken}` },
cache: 'no-store',
},
);
const users = res.ok ? await res.json() : [];
return <AnvandareClient users={users} currentUserId={userId ?? 0} />;
}
+22
View File
@@ -0,0 +1,22 @@
import { fetchJson } from '../../../lib/api';
import type { Product } from '../../../features/inventory/types';
import MergePreviewForm from '../../admin/products/MergePreviewForm';
import AdminProductList from '../../admin/products/AdminProductList';
import ExpandableCreateProductSection from '../../admin/products/ExpandableCreateProductSection';
import ResetProductsButton from '../../admin/products/ResetProductsButton';
export default async function DatabsTab() {
const products = await fetchJson<Product[]>('/api/products');
return (
<div>
<p style={{ color: '#555', marginBottom: '1.5rem' }}>
Granska och standardisera produktnamn, slå ihop dubbletter och hantera kategorier.
</p>
<ExpandableCreateProductSection />
<ResetProductsButton />
<MergePreviewForm products={products} />
<AdminProductList products={products} />
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
'use client';
import { useState, useEffect, FormEvent } from 'react';
type Profile = {
id: number;
username: string;
email: string;
firstName: string | null;
lastName: string | null;
};
export default function ProfileClient() {
const [profile, setProfile] = useState<Profile | null>(null);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/profile')
.then((r) => r.json())
.then((data: Profile) => {
setProfile(data);
setForm({
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
email: data.email,
});
})
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
setSaving(true);
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: form.firstName || null,
lastName: form.lastName || null,
email: form.email,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte spara ändringar');
} else {
setSuccess(true);
}
} catch {
setError('Något gick fel');
} finally {
setSaving(false);
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
borderRadius: 6,
border: '1px solid #ddd',
fontSize: '1rem',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 6,
fontWeight: 500,
fontSize: '0.9rem',
color: '#444',
};
if (loading) {
return <p style={{ color: '#666' }}>Laddar profil...</p>;
}
return (
<div style={{ maxWidth: 480 }}>
{/* Initialer/avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: '#2563eb',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{profile?.username?.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>{profile?.username}</div>
<div style={{ color: '#666', fontSize: '0.9rem' }}>{profile?.email}</div>
</div>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label htmlFor="firstName" style={labelStyle}>Förnamn</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="given-name"
/>
</div>
<div>
<label htmlFor="lastName" style={labelStyle}>Efternamn</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="family-name"
/>
</div>
</div>
<div>
<label htmlFor="email" style={labelStyle}>E-post</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
style={inputStyle}
autoComplete="email"
/>
</div>
<div>
<label style={labelStyle}>Användarnamn</label>
<input
type="text"
value={profile?.username ?? ''}
disabled
style={{ ...inputStyle, background: '#f5f5f5', color: '#888', cursor: 'not-allowed' }}
/>
<p style={{ fontSize: '0.8rem', color: '#999', margin: '4px 0 0' }}>
Användarnamn kan inte ändras
</p>
</div>
{error && <p style={{ color: '#dc2626', margin: 0 }}>{error}</p>}
{success && <p style={{ color: '#16a34a', margin: 0 }}>Ändringarna sparades!</p>}
<button
type="submit"
disabled={saving}
style={{
padding: '10px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: '1rem',
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
fontWeight: 500,
}}
>
{saving ? 'Sparar...' : 'Spara ändringar'}
</button>
</form>
</div>
);
}