feat: add user profile page with name and email editing
Backend: new UpdateUserProfileUseCase with email uniqueness validation, PUT /auth/profile endpoint. Frontend: ProfilePage with avatar initials, inline success/error feedback, and refreshUser() sync after save. Avatar dropdown in AppShell gains a "Meu Perfil" link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5a71aba13f
commit
769791b25c
13 changed files with 312 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import net.beetechgroup.beetask.usecase.auth.login.LoginUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenUseCase;
|
import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase;
|
import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase;
|
import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import io.smallrye.jwt.auth.principal.JWTParser;
|
import io.smallrye.jwt.auth.principal.JWTParser;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
@ -40,4 +41,9 @@ public class UserUseCaseConfig {
|
||||||
public GetUserProfileUseCase getUserProfileUseCase(UserRepository userRepository) {
|
public GetUserProfileUseCase getUserProfileUseCase(UserRepository userRepository) {
|
||||||
return new GetUserProfileUseCase(userRepository);
|
return new GetUserProfileUseCase(userRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public UpdateUserProfileUseCase updateUserProfileUseCase(UserRepository userRepository) {
|
||||||
|
return new UpdateUserProfileUseCase(userRepository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
|
||||||
|
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
|
@ -18,6 +19,9 @@ import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.user.create.CreateUserInput;
|
import net.beetechgroup.beetask.usecase.user.create.CreateUserInput;
|
||||||
import net.beetechgroup.beetask.usecase.user.create.CreateUserOutput;
|
import net.beetechgroup.beetask.usecase.user.create.CreateUserOutput;
|
||||||
import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase;
|
import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput;
|
||||||
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
|
||||||
|
|
||||||
import io.quarkus.security.Authenticated;
|
import io.quarkus.security.Authenticated;
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
|
@ -45,6 +49,9 @@ public class AuthController {
|
||||||
@Inject
|
@Inject
|
||||||
GetUserProfileUseCase getUserProfileUseCase;
|
GetUserProfileUseCase getUserProfileUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
UpdateUserProfileUseCase updateUserProfileUseCase;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
SecurityIdentity securityIdentity;
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
|
@ -58,6 +65,19 @@ public class AuthController {
|
||||||
return getUserProfileUseCase.execute(email);
|
return getUserProfileUseCase.execute(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Path("/profile")
|
||||||
|
@PUT
|
||||||
|
@Authenticated
|
||||||
|
@Operation(summary = "Update profile", description = "Updates the name and email of the logged in user")
|
||||||
|
public UpdateProfileResponse updateProfile(UpdateProfileRequest request) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
LOGGER.infof("Profile update requested for user %s", email);
|
||||||
|
UpdateUserProfileInput input = AuthControllerMapper.toUpdateUserProfileInput(request, email);
|
||||||
|
UpdateUserProfileOutput output = updateUserProfileUseCase.execute(input);
|
||||||
|
LOGGER.infof("Profile updated successfully for user, new email: %s", output.email());
|
||||||
|
return AuthControllerMapper.toUpdateProfileResponse(output);
|
||||||
|
}
|
||||||
|
|
||||||
@Path("/register")
|
@Path("/register")
|
||||||
@POST
|
@POST
|
||||||
@Operation(summary = "Register", description = "Creates a new user account")
|
@Operation(summary = "Register", description = "Creates a new user account")
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import net.beetechgroup.beetask.usecase.auth.login.LoginOutput;
|
||||||
import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenInput;
|
import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenInput;
|
||||||
import net.beetechgroup.beetask.usecase.user.create.CreateUserInput;
|
import net.beetechgroup.beetask.usecase.user.create.CreateUserInput;
|
||||||
import net.beetechgroup.beetask.usecase.user.create.CreateUserOutput;
|
import net.beetechgroup.beetask.usecase.user.create.CreateUserOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput;
|
||||||
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class AuthControllerMapper {
|
public class AuthControllerMapper {
|
||||||
|
|
@ -39,4 +41,12 @@ public class AuthControllerMapper {
|
||||||
.collect(Collectors.toList())
|
.collect(Collectors.toList())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static UpdateUserProfileInput toUpdateUserProfileInput(UpdateProfileRequest request, String currentEmail) {
|
||||||
|
return new UpdateUserProfileInput(currentEmail, request.name(), request.email());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UpdateProfileResponse toUpdateProfileResponse(UpdateUserProfileOutput output) {
|
||||||
|
return new UpdateProfileResponse(output.name(), output.email(), output.photo());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
|
||||||
|
|
||||||
|
public record UpdateProfileRequest(String name, String email) {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
|
||||||
|
|
||||||
|
public record UpdateProfileResponse(String name, String email, String photo) {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.user.update;
|
||||||
|
|
||||||
|
public record UpdateUserProfileInput(String currentEmail, String name, String newEmail) {}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.user.update;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public class UpdateUserProfileMapper {
|
||||||
|
|
||||||
|
public static UpdateUserProfileOutput toOutput(User user) {
|
||||||
|
String photo = Objects.nonNull(user.getPhoto())
|
||||||
|
? user.getPhoto()
|
||||||
|
: "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random";
|
||||||
|
return new UpdateUserProfileOutput(user.getName(), user.getEmail(), photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.user.update;
|
||||||
|
|
||||||
|
public record UpdateUserProfileOutput(String name, String email, String photo) {}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.user.update;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class UpdateUserProfileUseCase {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(UpdateUserProfileUseCase.class);
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UpdateUserProfileUseCase(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateUserProfileOutput execute(UpdateUserProfileInput input) {
|
||||||
|
User user = userRepository.findByEmail(input.currentEmail())
|
||||||
|
.orElseThrow(() -> {
|
||||||
|
LOGGER.warnf("Profile update failed: user %s not found", input.currentEmail());
|
||||||
|
return new RuntimeException("User not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!input.currentEmail().equals(input.newEmail())) {
|
||||||
|
boolean emailTaken = userRepository.findByEmail(input.newEmail()).isPresent();
|
||||||
|
if (emailTaken) {
|
||||||
|
LOGGER.warnf("Profile update rejected: email %s already in use", input.newEmail());
|
||||||
|
throw new RuntimeException("Email já está em uso por outro usuário");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setName(input.name());
|
||||||
|
user.setEmail(input.newEmail());
|
||||||
|
User saved = userRepository.save(user);
|
||||||
|
|
||||||
|
LOGGER.infof("Profile updated for user %d: name=%s, email=%s", saved.getId(), saved.getName(), saved.getEmail());
|
||||||
|
|
||||||
|
String photo = Objects.nonNull(saved.getPhoto())
|
||||||
|
? saved.getPhoto()
|
||||||
|
: "https://ui-avatars.com/api/?name=" + saved.getName().replace(" ", "+") + "&background=random";
|
||||||
|
|
||||||
|
return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -422,6 +422,14 @@ export function AppShell() {
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
onClick={() => setShowAvatarDropdown(false)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold text-text-muted hover:bg-surface-muted hover:text-text-main transition-all"
|
||||||
|
>
|
||||||
|
<User size={16} />
|
||||||
|
Meu Perfil
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAvatarDropdown(false);
|
setShowAvatarDropdown(false);
|
||||||
|
|
|
||||||
169
frontend/src/pages/ProfilePage.tsx
Normal file
169
frontend/src/pages/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { User, Mail, Save, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { userService } from "../services/userService";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export const ProfilePage: React.FC = () => {
|
||||||
|
const { user, refreshUser } = useAuth();
|
||||||
|
|
||||||
|
const [name, setName] = useState(user?.name ?? "");
|
||||||
|
const [email, setEmail] = useState(user?.email ?? "");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(user?.name ?? "");
|
||||||
|
setEmail(user?.email ?? "");
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const initials = (name || user?.name || "?")
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const hasChanges = name !== user?.name || email !== user?.email;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !email.trim()) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
setErrorMsg(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.updateProfile({ name: name.trim(), email: email.trim() });
|
||||||
|
await refreshUser();
|
||||||
|
setSuccessMsg("Perfil atualizado com sucesso!");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erro ao atualizar perfil.";
|
||||||
|
setErrorMsg(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="mx-auto max-w-lg"
|
||||||
|
>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-black text-text-main">Meu Perfil</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
|
Gerencie suas informações pessoais
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border-soft bg-surface p-8 shadow-sm">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="mb-8 flex flex-col items-center gap-3">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand/10 text-2xl font-black text-brand ring-4 ring-brand/10">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-bold text-text-main">{user?.name}</p>
|
||||||
|
<p className="text-sm text-text-muted">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Nome */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-text-main">
|
||||||
|
Nome
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-border-soft bg-surface-muted py-2.5 pl-9 pr-4 text-sm text-text-main placeholder:text-text-muted focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20"
|
||||||
|
placeholder="Seu nome completo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-text-main">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-border-soft bg-surface-muted py-2.5 pl-9 pr-4 text-sm text-text-main placeholder:text-text-muted focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20"
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{successMsg && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||||
|
>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
{successMsg}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{errorMsg && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||||
|
>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{errorMsg}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botão */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving || !hasChanges}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-bold transition-all",
|
||||||
|
hasChanges && !isSaving
|
||||||
|
? "bg-brand text-white shadow-md shadow-brand/20 hover:scale-[1.01] hover:bg-brand-dark active:scale-95"
|
||||||
|
: "cursor-not-allowed bg-surface-muted text-text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={16} />
|
||||||
|
Salvar alterações
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -17,6 +17,7 @@ import { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
|
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
|
||||||
import { RoleGate } from "./components/RoleGate";
|
import { RoleGate } from "./components/RoleGate";
|
||||||
import { LandingPage } from "./pages/LandingPage";
|
import { LandingPage } from "./pages/LandingPage";
|
||||||
|
import { ProfilePage } from "./pages/ProfilePage";
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -103,6 +104,10 @@ export const router = createBrowserRouter([
|
||||||
</RoleGate>
|
</RoleGate>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "profile",
|
||||||
|
element: <ProfilePage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
21
frontend/src/services/userService.ts
Normal file
21
frontend/src/services/userService.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { apiFetch } from '../lib/api'
|
||||||
|
|
||||||
|
export interface UpdateProfilePayload {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileResult {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
photo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userService = {
|
||||||
|
updateProfile: async (payload: UpdateProfilePayload): Promise<UpdateProfileResult> => {
|
||||||
|
return apiFetch<UpdateProfileResult>('/auth/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue