v1.47.0 - Página Meu Perfil: editar nome, email e senha

This commit is contained in:
marcoitaloesp-ai 2025-12-17 10:22:04 +00:00 committed by GitHub
parent c04dfd339c
commit 38defe1060
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 527 additions and 1 deletions

View File

@ -5,6 +5,22 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.47.0] - 2025-12-17
### Added
- 👤 **Página Meu Perfil** - Gerenciamento de dados pessoais do usuário
- Editar nome e email
- Alterar senha (validação de senha atual obrigatória)
- Validação em tempo real dos campos
- Feedback visual de erros e sucesso
- Traduções completas: PT-BR, EN, ES
- Link no menu Configurações > Perfil
### Technical Details
- Backend: `PUT /api/profile` no AuthController
- Frontend: `Profile.jsx` com formulários separados para dados e senha
- Service: `profileService` em api.js com atualização automática do localStorage
## [1.44.6] - 2025-12-17
### Changed

View File

@ -1 +1 @@
1.44.6
1.47.0

View File

@ -179,5 +179,86 @@ public function me(Request $request): JsonResponse
], 500);
}
}
/**
* Update user profile (name, email, password)
*/
public function updateProfile(Request $request): JsonResponse
{
try {
$user = $request->user();
$rules = [
'name' => 'sometimes|required|string|max:255',
'email' => 'sometimes|required|string|email|max:255|unique:users,email,' . $user->id,
'current_password' => 'required_with:new_password',
'new_password' => 'nullable|string|min:8|confirmed',
];
$messages = [
'name.required' => 'O nome é obrigatório',
'name.max' => 'O nome deve ter no máximo 255 caracteres',
'email.required' => 'O email é obrigatório',
'email.email' => 'O email deve ser válido',
'email.unique' => 'Este email já está em uso',
'current_password.required_with' => 'A senha atual é obrigatória para alterar a senha',
'new_password.min' => 'A nova senha deve ter pelo menos 8 caracteres',
'new_password.confirmed' => 'As senhas não coincidem',
];
$validator = Validator::make($request->all(), $rules, $messages);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Erro de validação',
'errors' => $validator->errors()
], 422);
}
// Verificar senha atual se estiver alterando senha
if ($request->filled('new_password')) {
if (!Hash::check($request->current_password, $user->password)) {
return response()->json([
'success' => false,
'message' => 'Senha atual incorreta',
'errors' => ['current_password' => ['A senha atual está incorreta']]
], 422);
}
$user->password = Hash::make($request->new_password);
}
// Atualizar nome se fornecido
if ($request->has('name')) {
$user->name = $request->name;
}
// Atualizar email se fornecido
if ($request->has('email')) {
$user->email = $request->email;
}
$user->save();
return response()->json([
'success' => true,
'message' => 'Perfil atualizado com sucesso',
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
]
]
], 200);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Erro ao atualizar perfil',
'error' => $e->getMessage()
], 500);
}
}
}

View File

@ -36,6 +36,7 @@
// Auth routes
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
Route::put('/profile', [AuthController::class, 'updateProfile']);
Route::get('/user', function (Request $request) {
return $request->user();
});

View File

@ -22,6 +22,7 @@ import Goals from './pages/Goals';
import Budgets from './pages/Budgets';
import Reports from './pages/Reports';
import Preferences from './pages/Preferences';
import Profile from './pages/Profile';
function App() {
return (
@ -190,6 +191,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Layout>
<Profile />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
<CookieConsent />

View File

@ -104,6 +104,7 @@ const Layout = ({ children }) => {
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
]
},
];

View File

@ -2005,5 +2005,25 @@
"timezone": "Timezone",
"note": "Some changes may require page refresh."
}
},
"profile": {
"title": "My Profile",
"subtitle": "Manage your personal data and password",
"personalData": "Personal Data",
"name": "Name",
"email": "Email",
"saveProfile": "Save Profile",
"changePassword": "Change Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"passwordHint": "Minimum 8 characters",
"loadError": "Error loading profile",
"saveSuccess": "Profile updated successfully!",
"saveError": "Error saving profile",
"passwordChanged": "Password changed successfully!",
"passwordError": "Error changing password",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters"
}
}

View File

@ -1993,5 +1993,25 @@
"timezone": "Zona horaria",
"note": "Algunos cambios pueden requerir actualizar la página."
}
},
"profile": {
"title": "Mi Perfil",
"subtitle": "Gestiona tus datos personales y contraseña",
"personalData": "Datos Personales",
"name": "Nombre",
"email": "Email",
"saveProfile": "Guardar Perfil",
"changePassword": "Cambiar Contraseña",
"currentPassword": "Contraseña Actual",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Nueva Contraseña",
"passwordHint": "Mínimo 8 caracteres",
"loadError": "Error al cargar perfil",
"saveSuccess": "¡Perfil actualizado con éxito!",
"saveError": "Error al guardar perfil",
"passwordChanged": "¡Contraseña cambiada con éxito!",
"passwordError": "Error al cambiar contraseña",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres"
}
}

View File

@ -2011,5 +2011,25 @@
"timezone": "Fuso horário",
"note": "Algumas alterações podem exigir atualização da página."
}
},
"profile": {
"title": "Meu Perfil",
"subtitle": "Gerencie seus dados pessoais e senha",
"personalData": "Dados Pessoais",
"name": "Nome",
"email": "Email",
"saveProfile": "Salvar Perfil",
"changePassword": "Alterar Senha",
"currentPassword": "Senha Atual",
"newPassword": "Nova Senha",
"confirmPassword": "Confirmar Nova Senha",
"passwordHint": "Mínimo de 8 caracteres",
"loadError": "Erro ao carregar perfil",
"saveSuccess": "Perfil atualizado com sucesso!",
"saveError": "Erro ao salvar perfil",
"passwordChanged": "Senha alterada com sucesso!",
"passwordError": "Erro ao alterar senha",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres"
}
}

View File

@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../components/Toast';
import { profileService } from '../services/api';
export default function Profile() {
const { t } = useTranslation();
const { showToast } = useToast();
const [loading, setLoading] = useState(true);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
// Dados do perfil
const [profile, setProfile] = useState({
name: '',
email: '',
});
// Dados de alteração de senha
const [passwordData, setPasswordData] = useState({
current_password: '',
new_password: '',
new_password_confirmation: '',
});
// Erros de validação
const [profileErrors, setProfileErrors] = useState({});
const [passwordErrors, setPasswordErrors] = useState({});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
setLoading(true);
const response = await profileService.get();
if (response.success) {
setProfile({
name: response.data.user.name || '',
email: response.data.user.email || '',
});
}
} catch (error) {
console.error('Error loading profile:', error);
showToast(t('profile.loadError'), 'error');
} finally {
setLoading(false);
}
};
const handleProfileChange = (field, value) => {
setProfile(prev => ({
...prev,
[field]: value,
}));
// Limpar erro do campo
if (profileErrors[field]) {
setProfileErrors(prev => ({ ...prev, [field]: null }));
}
};
const handlePasswordChange = (field, value) => {
setPasswordData(prev => ({
...prev,
[field]: value,
}));
// Limpar erro do campo
if (passwordErrors[field]) {
setPasswordErrors(prev => ({ ...prev, [field]: null }));
}
};
const handleSaveProfile = async (e) => {
e.preventDefault();
setProfileErrors({});
try {
setSavingProfile(true);
const response = await profileService.update({
name: profile.name,
email: profile.email,
});
if (response.success) {
showToast(t('profile.saveSuccess'), 'success');
}
} catch (error) {
console.error('Error saving profile:', error);
if (error.response?.data?.errors) {
setProfileErrors(error.response.data.errors);
}
showToast(error.response?.data?.message || t('profile.saveError'), 'error');
} finally {
setSavingProfile(false);
}
};
const handleChangePassword = async (e) => {
e.preventDefault();
setPasswordErrors({});
// Validação básica no frontend
if (passwordData.new_password !== passwordData.new_password_confirmation) {
setPasswordErrors({ new_password_confirmation: [t('profile.passwordMismatch')] });
return;
}
if (passwordData.new_password.length < 8) {
setPasswordErrors({ new_password: [t('profile.passwordTooShort')] });
return;
}
try {
setSavingPassword(true);
const response = await profileService.update({
current_password: passwordData.current_password,
new_password: passwordData.new_password,
new_password_confirmation: passwordData.new_password_confirmation,
});
if (response.success) {
showToast(t('profile.passwordChanged'), 'success');
// Limpar campos de senha
setPasswordData({
current_password: '',
new_password: '',
new_password_confirmation: '',
});
}
} catch (error) {
console.error('Error changing password:', error);
if (error.response?.data?.errors) {
setPasswordErrors(error.response.data.errors);
}
showToast(error.response?.data?.message || t('profile.passwordError'), 'error');
} finally {
setSavingPassword(false);
}
};
if (loading) {
return (
<div className="container-fluid py-4">
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
</div>
);
}
return (
<div className="container-fluid py-4">
<div className="row justify-content-center">
<div className="col-lg-8 col-xl-6">
{/* Header */}
<div className="d-flex align-items-center mb-4">
<i className="bi bi-person-circle fs-2 text-primary me-3"></i>
<div>
<h2 className="mb-0">{t('profile.title')}</h2>
<p className="text-muted mb-0">{t('profile.subtitle')}</p>
</div>
</div>
{/* Dados Pessoais */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-person me-2"></i>
{t('profile.personalData')}
</h5>
</div>
<div className="card-body">
<form onSubmit={handleSaveProfile}>
{/* Nome */}
<div className="mb-3">
<label htmlFor="name" className="form-label">
{t('profile.name')} <span className="text-danger">*</span>
</label>
<input
type="text"
className={`form-control ${profileErrors.name ? 'is-invalid' : ''}`}
id="name"
value={profile.name}
onChange={(e) => handleProfileChange('name', e.target.value)}
required
/>
{profileErrors.name && (
<div className="invalid-feedback">{profileErrors.name[0]}</div>
)}
</div>
{/* Email */}
<div className="mb-3">
<label htmlFor="email" className="form-label">
{t('profile.email')} <span className="text-danger">*</span>
</label>
<input
type="email"
className={`form-control ${profileErrors.email ? 'is-invalid' : ''}`}
id="email"
value={profile.email}
onChange={(e) => handleProfileChange('email', e.target.value)}
required
/>
{profileErrors.email && (
<div className="invalid-feedback">{profileErrors.email[0]}</div>
)}
</div>
{/* Botão Salvar Perfil */}
<div className="d-flex justify-content-end">
<button
type="submit"
className="btn btn-primary"
disabled={savingProfile}
>
{savingProfile ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
{t('profile.saveProfile')}
</>
)}
</button>
</div>
</form>
</div>
</div>
{/* Alterar Senha */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-shield-lock me-2"></i>
{t('profile.changePassword')}
</h5>
</div>
<div className="card-body">
<form onSubmit={handleChangePassword}>
{/* Senha Atual */}
<div className="mb-3">
<label htmlFor="current_password" className="form-label">
{t('profile.currentPassword')} <span className="text-danger">*</span>
</label>
<input
type="password"
className={`form-control ${passwordErrors.current_password ? 'is-invalid' : ''}`}
id="current_password"
value={passwordData.current_password}
onChange={(e) => handlePasswordChange('current_password', e.target.value)}
required
autoComplete="current-password"
/>
{passwordErrors.current_password && (
<div className="invalid-feedback">{passwordErrors.current_password[0]}</div>
)}
</div>
{/* Nova Senha */}
<div className="mb-3">
<label htmlFor="new_password" className="form-label">
{t('profile.newPassword')} <span className="text-danger">*</span>
</label>
<input
type="password"
className={`form-control ${passwordErrors.new_password ? 'is-invalid' : ''}`}
id="new_password"
value={passwordData.new_password}
onChange={(e) => handlePasswordChange('new_password', e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
{passwordErrors.new_password && (
<div className="invalid-feedback">{passwordErrors.new_password[0]}</div>
)}
<div className="form-text">{t('profile.passwordHint')}</div>
</div>
{/* Confirmar Nova Senha */}
<div className="mb-3">
<label htmlFor="new_password_confirmation" className="form-label">
{t('profile.confirmPassword')} <span className="text-danger">*</span>
</label>
<input
type="password"
className={`form-control ${passwordErrors.new_password_confirmation ? 'is-invalid' : ''}`}
id="new_password_confirmation"
value={passwordData.new_password_confirmation}
onChange={(e) => handlePasswordChange('new_password_confirmation', e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
{passwordErrors.new_password_confirmation && (
<div className="invalid-feedback">{passwordErrors.new_password_confirmation[0]}</div>
)}
</div>
{/* Botão Alterar Senha */}
<div className="d-flex justify-content-end">
<button
type="submit"
className="btn btn-warning"
disabled={savingPassword}
>
{savingPassword ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-key me-2"></i>
{t('profile.changePassword')}
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1517,4 +1517,25 @@ export const preferencesService = {
},
};
// ============================================
// Profile Service (Perfil do Usuário)
// ============================================
export const profileService = {
// Obter dados do usuário
get: async () => {
const response = await api.get('/me');
return response.data;
},
// Atualizar perfil (nome, email, senha)
update: async (data) => {
const response = await api.put('/profile', data);
// Atualizar localStorage se sucesso
if (response.data.success && response.data.data?.user) {
localStorage.setItem('user', JSON.stringify(response.data.data.user));
}
return response.data;
},
};
export default api;