feat: add TypeScript definitions for next-auth session with accessToken and user details
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-04 20:09:21 +02:00
parent afd2607000
commit ffe50e5151
135 changed files with 5 additions and 38 deletions
@@ -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>
);
}
@@ -0,0 +1,61 @@
'use client';
import Link from 'next/link';
type Tab = { id: string; label: string };
const USER_TABS: Tab[] = [
{ id: 'profil', label: 'Min profil' },
{ id: 'databas', label: '🗄️ Databas' },
];
const ADMIN_TABS: Tab[] = [
{ id: 'profil', label: 'Min profil' },
{ id: 'anvandare', label: '👥 Användare' },
{ id: 'databas', label: '🗄️ Databas' },
{ id: 'forslag', label: '⏳ Förslag' },
{ id: 'ai', label: '🤖 AI' },
];
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>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { auth } from '../../auth';
import Navigation from '../Navigation';
import ProfileTabs from './ProfileTabs';
import MinProfilTab from './tabs/MinProfilTab';
export const metadata = { title: 'Min profil' };
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') {
const { default: DatabsTab } = await import('./tabs/DatabsTab');
TabContent = DatabsTab;
} else if (tab === 'anvandare' && isAdmin) {
const { default: AnvandareTab } = await import('./tabs/AnvandareTab');
TabContent = AnvandareTab;
} else if (tab === 'forslag' && isAdmin) {
const { default: ForslagTab } = await import('./tabs/ForslagTab');
TabContent = ForslagTab;
} else if (tab === 'ai' && isAdmin) {
const { default: AiTab } = await import('./tabs/AiTab');
TabContent = AiTab;
} else {
TabContent = MinProfilTab;
}
const adminTabs = ['anvandare', 'forslag', 'ai'];
const userTabs = ['databas'];
const activeTab =
(isAdmin && (adminTabs.includes(tab) || userTabs.includes(tab))) ||
userTabs.includes(tab)
? tab
: 'profil';
return (
<>
<Navigation />
<main style={{ padding: '1rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Min profil</h1>
<ProfileTabs activeTab={activeTab} isAdmin={isAdmin} />
<TabContent />
</main>
</>
);
}
@@ -0,0 +1,28 @@
import AiAdminClient from '../../admin/ai/AiAdminClient';
import type { AiModelInfo } from '../../admin/ai/AiAdminClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export default async function AiTab() {
const key = process.env.MISTRAL_API_KEY ?? '';
const hasKey = key.length > 0;
const keyHint = key.length >= 4 ? key.slice(-4) : '????';
let aiFunctions: AiModelInfo[] = [];
try {
const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' });
if (res.ok) aiFunctions = await res.json();
} catch {
// backend ej nåbart
}
return (
<div>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.25rem' }}>🤖 AI-konfiguration</h2>
<p style={{ color: '#666', marginBottom: '2rem', fontSize: '0.9rem' }}>
Översikt över implementerade AI-funktioner och API-nyckelstatus.
</p>
<AiAdminClient keyHint={keyHint} hasKey={hasKey} aiFunctions={aiFunctions} />
</div>
);
}
@@ -0,0 +1,611 @@
'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>
);
}
@@ -0,0 +1,23 @@
import { auth } from '../../../auth';
import { getAuthHeaders } from '../../../lib/auth-headers';
import AnvandareClient from './AnvandareClient';
export default async function AnvandareTab() {
const session = await auth();
const userId = Number(session?.user?.id ?? 0);
const headers = await getAuthHeaders();
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'}/api/users`,
{ headers, cache: 'no-store' },
);
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`[AnvandareTab] GET /api/users misslyckades: ${res.status}`, text);
}
const users = res.ok ? await res.json() : [];
return <AnvandareClient users={users} currentUserId={userId} />;
}
@@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import ProductsView from './views/ProductsView';
import InventoryView from './views/InventoryView';
import PantryView from './views/PantryView';
type TableId = 'inventory' | 'pantry' | 'products';
type TableDef = {
id: TableId;
label: string;
adminOnly: boolean;
};
const TABLES: TableDef[] = [
{ id: 'inventory', label: '🏠 Inventarie', adminOnly: false },
{ id: 'pantry', label: '📦 Baslager', adminOnly: false },
{ id: 'products', label: '🗄️ Produkter', adminOnly: true },
];
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '0.5rem 1.1rem',
border: 'none',
borderBottom: active ? '2.5px solid #2563eb' : '2.5px solid transparent',
background: active ? '#eff6ff' : 'none',
cursor: 'pointer',
fontWeight: active ? 600 : 400,
color: active ? '#2563eb' : '#555',
fontSize: '0.95rem',
borderRadius: '4px 4px 0 0',
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
});
export default function DatabsTab() {
const { data: session } = useSession();
const isAdmin = (session?.user as any)?.role === 'admin';
const visibleTables = TABLES.filter((t) => !t.adminOnly || isAdmin);
const [activeTable, setActiveTable] = useState<TableId>('inventory');
// Om vald tabell inte är tillgänglig för rollen, fall tillbaka på första
const safeActive = visibleTables.find((t) => t.id === activeTable)
? activeTable
: visibleTables[0]?.id ?? 'inventory';
return (
<div>
{/* Tabellväljare */}
<div
style={{
display: 'flex',
gap: '0.25rem',
borderBottom: '1px solid #ddd',
marginBottom: '1.75rem',
}}
>
{visibleTables.map((table) => (
<button
key={table.id}
style={tabStyle(safeActive === table.id)}
onClick={() => setActiveTable(table.id)}
>
{table.label}
</button>
))}
</div>
{safeActive === 'inventory' && <InventoryView />}
{safeActive === 'pantry' && <PantryView />}
{safeActive === 'products' && isAdmin && <ProductsView />}
</div>
);
}
@@ -0,0 +1,25 @@
import { getAuthHeaders } from '../../../lib/auth-headers';
import PendingProductsClient from '../../admin/products/pending/PendingProductsClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
export default async function ForslagTab() {
const headers = await getAuthHeaders();
let products: any[] = [];
try {
const res = await fetch(`${API_BASE}/api/products/pending`, { headers, cache: 'no-store' });
if (res.ok) products = await res.json();
} catch {
// backend ej nåbart
}
return (
<div>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.5rem' }}>Väntande produktförslag</h2>
<p style={{ color: '#64748b', marginBottom: '1.5rem', fontSize: '0.9rem' }}>
Produkter som användare har föreslagit. Godkänn för att göra dem tillgängliga i katalogen, eller avvisa för att ta bort dem.
</p>
<PendingProductsClient 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>
);
}
@@ -0,0 +1,54 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { InventoryItem, Product } from '../../../../features/inventory/types';
import InventoryList from '../../../inventory/InventoryList';
import InventoryForm from '../../../inventory/InventoryForm';
import { useAuthFetch } from '../../../../lib/use-auth-fetch';
export default function InventoryView() {
const [inventory, setInventory] = useState<InventoryItem[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const authFetch = useAuthFetch();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [invRes, prodRes] = await Promise.all([
authFetch('/api/inventory'),
fetch('/api/products'),
]);
if (!invRes.ok) throw new Error('Kunde inte hämta inventarie');
if (!prodRes.ok) throw new Error('Kunde inte hämta produkter');
const [inv, prods] = await Promise.all([invRes.json(), prodRes.json()]);
setInventory(inv);
setProducts(prods);
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel');
} finally {
setLoading(false);
}
}, [authFetch]);
useEffect(() => { load(); }, [load]);
if (loading) return <p style={{ color: '#888' }}>Laddar inventarie</p>;
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
return (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Lägg till, redigera och ta bort varor i ditt inventarie.
</p>
{/* Formulär för att lägga till vara — viker ut sig vid klick */}
<InventoryForm products={products} onCreated={load} />
{/* Lista med redigera/ta bort per rad */}
<InventoryList inventory={inventory} onDeleted={load} />
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Product } from '../../../../features/inventory/types';
import AddToPantryForm from '../../../baslager/AddToPantryForm';
import PantryList from '../../../baslager/PantryList';
import { useAuthFetch } from '../../../../lib/use-auth-fetch';
type PantryItem = {
id: number;
productId: number;
createdAt: string;
updatedAt: string;
product: Product;
};
export default function PantryView() {
const [pantryItems, setPantryItems] = useState<PantryItem[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const authFetch = useAuthFetch();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [pantryRes, prodRes] = await Promise.all([
authFetch('/api/pantry'),
fetch('/api/products'),
]);
if (!pantryRes.ok) throw new Error('Kunde inte hämta baslager');
if (!prodRes.ok) throw new Error('Kunde inte hämta produkter');
const [pantry, prods] = await Promise.all([pantryRes.json(), prodRes.json()]);
setPantryItems(pantry);
setProducts(prods);
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel');
} finally {
setLoading(false);
}
}, [authFetch]);
useEffect(() => { load(); }, [load]);
if (loading) return <p style={{ color: '#888' }}>Laddar baslager</p>;
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
const pantryProductIds = new Set(pantryItems.map((i) => i.productId));
return (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Produkter du alltid räknar med att ha hemma. Lägg till och ta bort varor i ditt baslager.
</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>Lägg till produkt</h2>
<AddToPantryForm products={products} pantryProductIds={pantryProductIds} onCreated={load} />
</section>
<section>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>
{pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret
</h2>
<PantryList items={pantryItems} onDeleted={load} />
</section>
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import MergePreviewForm from '../../../admin/products/MergePreviewForm';
import AdminProductList from '../../../admin/products/AdminProductList';
import ExpandableCreateProductSection from '../../../admin/products/ExpandableCreateProductSection';
import ResetProductsButton from '../../../admin/products/ResetProductsButton';
import DeletedProductsView from '../../../admin/products/DeletedProductsView';
const subTabs = [
{ id: 'varor', label: '📦 Varor' },
{ id: 'skapa-merge', label: ' Skapa / Slå ihop' },
{ id: 'papperskorg', label: '🗑️ Papperskorg' },
] as const;
type SubTabId = typeof subTabs[number]['id'];
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '0.4rem 1rem',
border: 'none',
borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent',
background: 'none',
cursor: 'pointer',
fontWeight: active ? 600 : 400,
color: active ? '#111' : '#666',
fontSize: '0.95rem',
transition: 'color 0.15s, border-color 0.15s',
});
export default function ProductsView() {
const [activeSubTab, setActiveSubTab] = useState<SubTabId>('varor');
return (
<div>
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '1px solid #ddd', marginBottom: '1.5rem' }}>
{subTabs.map((tab) => (
<button
key={tab.id}
style={tabStyle(activeSubTab === tab.id)}
onClick={() => setActiveSubTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{activeSubTab === 'varor' && (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Granska och standardisera produktnamn samt hantera kategorier.
</p>
<AdminProductList />
</div>
)}
{activeSubTab === 'skapa-merge' && (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter.
</p>
<ExpandableCreateProductSection />
<ResetProductsButton />
<MergePreviewForm />
</div>
)}
{activeSubTab === 'papperskorg' && <DeletedProductsView />}
</div>
);
}