Files
recipe-app/frontend/app/profil/tabs/AnvandareClient.tsx
T
Nils-Johan Gynther 054a19ed7c MAJOR UPPDATE: "First Ai"
feat: add AI categorization for products and enhance user management

- Integrated AI service for category suggestions in receipt import and product management.
- Added premium subscription feature for users with corresponding API endpoints.
- Implemented admin interface for managing pending product suggestions.
- Enhanced user management to include premium status and corresponding UI updates.
- Updated database schema to support new fields for premium status and product status.
2026-04-19 10:34:21 +02:00

612 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
interface User {
id: number;
username: string;
email: string;
role: string;
isPremium: boolean;
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 handlePremiumChange(id: number, isPremium: boolean) {
setError('');
const res = await fetch(`/api/admin-users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPremium }),
});
if (res.ok) {
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, isPremium } : u)));
} else {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte ändra plan');
}
}
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', 'Plan', 'Å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>
{/* Plan */}
<td style={{ padding: '0.6rem 0.8rem' }}>
<select
value={user.isPremium ? 'paid' : 'free'}
onChange={(e) => handlePremiumChange(user.id, e.target.value === 'paid')}
style={{
padding: '0.25rem 0.4rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 13,
background: user.isPremium ? '#fef9c3' : '#f8fafc',
color: user.isPremium ? '#854d0e' : '#334155',
fontWeight: user.isPremium ? 600 : 400,
}}
>
<option value="free">Free</option>
<option value="paid">Paid </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>
);
}