feat(profile): add user profile management with first and last name fields
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `User` ADD COLUMN `firstName` VARCHAR(191) NULL;
|
||||||
|
ALTER TABLE `User` ADD COLUMN `lastName` VARCHAR(191) NULL;
|
||||||
@@ -11,6 +11,8 @@ model User {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
passwordHash String
|
passwordHash String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Controller, Get, Patch, Body } from '@nestjs/common';
|
||||||
|
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
class UpdateProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
async getMe(@CurrentUser() user: { userId: number; username: string }) {
|
||||||
|
const found = await this.usersService.findById(user.userId);
|
||||||
|
return {
|
||||||
|
id: found?.id,
|
||||||
|
username: found?.username,
|
||||||
|
email: found?.email,
|
||||||
|
firstName: found?.firstName,
|
||||||
|
lastName: found?.lastName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
async updateMe(
|
||||||
|
@CurrentUser() user: { userId: number; username: string },
|
||||||
|
@Body() dto: UpdateProfileDto,
|
||||||
|
) {
|
||||||
|
const updated = await this.usersService.updateProfile(user.userId, dto);
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
username: updated.username,
|
||||||
|
email: updated.email,
|
||||||
|
firstName: updated.firstName,
|
||||||
|
lastName: updated.lastName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -16,4 +16,8 @@ export class UsersService {
|
|||||||
create(data: { username: string; email: string; passwordHash: string }) {
|
create(data: { username: string; email: string; passwordHash: string }) {
|
||||||
return this.prisma.user.create({ data });
|
return this.prisma.user.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateProfile(id: number, data: { firstName?: string; lastName?: string; email?: string }) {
|
||||||
|
return this.prisma.user.update({ where: { id }, data });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,37 @@ export default async function Navigation() {
|
|||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{session?.user && (
|
{session?.user && (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontSize: '0.9rem', color: '#555' }}>
|
<Link
|
||||||
👤 {session.user.name}
|
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>
|
</span>
|
||||||
|
{session.user.name}
|
||||||
|
</Link>
|
||||||
<form action={signOutAction}>
|
<form action={signOutAction}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user