feat: add user profile page with name and email editing

feat: add user profile page with name and email editing
This commit is contained in:
Gabriel Menezes 2026-05-21 15:06:28 -04:00 committed by GitHub
commit 15118177a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 312 additions and 0 deletions

View file

@ -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.user.create.CreateUserUseCase;
import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import io.smallrye.jwt.auth.principal.JWTParser;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ -40,4 +41,9 @@ public class UserUseCaseConfig {
public GetUserProfileUseCase getUserProfileUseCase(UserRepository userRepository) {
return new GetUserProfileUseCase(userRepository);
}
@Produces
public UpdateUserProfileUseCase updateUserProfileUseCase(UserRepository userRepository) {
return new UpdateUserProfileUseCase(userRepository);
}
}

View file

@ -2,6 +2,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
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.CreateUserOutput;
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.identity.SecurityIdentity;
@ -45,6 +49,9 @@ public class AuthController {
@Inject
GetUserProfileUseCase getUserProfileUseCase;
@Inject
UpdateUserProfileUseCase updateUserProfileUseCase;
@Inject
SecurityIdentity securityIdentity;
@ -58,6 +65,19 @@ public class AuthController {
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")
@POST
@Operation(summary = "Register", description = "Creates a new user account")

View file

@ -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.user.create.CreateUserInput;
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;
public class AuthControllerMapper {
@ -39,4 +41,12 @@ public class AuthControllerMapper {
.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());
}
}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
public record UpdateProfileRequest(String name, String email) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
public record UpdateProfileResponse(String name, String email, String photo) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.user.update;
public record UpdateUserProfileInput(String currentEmail, String name, String newEmail) {}

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.user.update;
public record UpdateUserProfileOutput(String name, String email, String photo) {}

View file

@ -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);
}
}

View file

@ -422,6 +422,14 @@ export function AppShell() {
{user?.email}
</p>
</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
onClick={() => {
setShowAvatarDropdown(false);

View 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>
);
};

View file

@ -17,6 +17,7 @@ import { DashboardPage } from "./pages/DashboardPage";
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
import { RoleGate } from "./components/RoleGate";
import { LandingPage } from "./pages/LandingPage";
import { ProfilePage } from "./pages/ProfilePage";
export const router = createBrowserRouter([
{
@ -103,6 +104,10 @@ export const router = createBrowserRouter([
</RoleGate>
),
},
{
path: "profile",
element: <ProfilePage />,
},
],
},
]);

View 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,
})
},
}