feat(profile): add user profile management with first and last name fields

This commit is contained in:
Nils-Johan Gynther
2026-04-17 20:44:23 +02:00
parent 68b29f6d8e
commit a9e83544c5
9 changed files with 329 additions and 3 deletions
+31 -3
View File
@@ -39,9 +39,37 @@ export default async function Navigation() {
<span style={{ flex: 1 }} />
{session?.user && (
<>
<span style={{ fontSize: '0.9rem', color: '#555' }}>
👤 {session.user.name}
</span>
<Link
href="/profil"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
fontSize: '0.9rem',
color: '#555',
textDecoration: 'none',
padding: '0.3rem 0.5rem',
borderRadius: 4,
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
background: '#2563eb',
color: 'white',
fontWeight: 700,
fontSize: '0.85rem',
}}
>
{session.user.name?.charAt(0).toUpperCase()}
</span>
{session.user.name}
</Link>
<form action={signOutAction}>
<button
type="submit"
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthHeaders } from '../../../lib/auth-headers';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function GET() {
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/users/me`, {
headers: { ...authHeaders },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
export async function PATCH(request: NextRequest) {
const authHeaders = await getAuthHeaders();
const body = await request.json();
const res = await fetch(`${API_BASE}/api/users/me`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
+188
View File
@@ -0,0 +1,188 @@
'use client';
import { useState, useEffect, FormEvent } from 'react';
type Profile = {
id: number;
username: string;
email: string;
firstName: string | null;
lastName: string | null;
};
export default function ProfileClient() {
const [profile, setProfile] = useState<Profile | null>(null);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/profile')
.then((r) => r.json())
.then((data: Profile) => {
setProfile(data);
setForm({
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
email: data.email,
});
})
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
setSaving(true);
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: form.firstName || null,
lastName: form.lastName || null,
email: form.email,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte spara ändringar');
} else {
setSuccess(true);
}
} catch {
setError('Något gick fel');
} finally {
setSaving(false);
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
borderRadius: 6,
border: '1px solid #ddd',
fontSize: '1rem',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 6,
fontWeight: 500,
fontSize: '0.9rem',
color: '#444',
};
if (loading) {
return <p style={{ color: '#666' }}>Laddar profil...</p>;
}
return (
<div style={{ maxWidth: 480 }}>
{/* Initialer/avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: '#2563eb',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{profile?.username?.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>{profile?.username}</div>
<div style={{ color: '#666', fontSize: '0.9rem' }}>{profile?.email}</div>
</div>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label htmlFor="firstName" style={labelStyle}>Förnamn</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="given-name"
/>
</div>
<div>
<label htmlFor="lastName" style={labelStyle}>Efternamn</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="family-name"
/>
</div>
</div>
<div>
<label htmlFor="email" style={labelStyle}>E-post</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
style={inputStyle}
autoComplete="email"
/>
</div>
<div>
<label style={labelStyle}>Användarnamn</label>
<input
type="text"
value={profile?.username ?? ''}
disabled
style={{ ...inputStyle, background: '#f5f5f5', color: '#888', cursor: 'not-allowed' }}
/>
<p style={{ fontSize: '0.8rem', color: '#999', margin: '4px 0 0' }}>
Användarnamn kan inte ändras
</p>
</div>
{error && <p style={{ color: '#dc2626', margin: 0 }}>{error}</p>}
{success && <p style={{ color: '#16a34a', margin: 0 }}>Ändringarna sparades!</p>}
<button
type="submit"
disabled={saving}
style={{
padding: '10px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: '1rem',
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
fontWeight: 500,
}}
>
{saving ? 'Sparar...' : 'Spara ändringar'}
</button>
</form>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Navigation from '../Navigation';
import ProfileClient from './ProfileClient';
export const metadata = { title: 'Min profil' };
export default function ProfilPage() {
return (
<>
<Navigation />
<main style={{ padding: '1rem', maxWidth: '800px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Min profil</h1>
<ProfileClient />
</main>
</>
);
}