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:
+19
-9
@@ -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')` på `merge`, `delete`, `restore`, `reset-all`, `bulk-update`, `backfill-canonical` i `products.controller.ts`
|
- **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
|
**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.**
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user