v1.47.0 - Página Meu Perfil: editar nome, email e senha
This commit is contained in:
parent
c04dfd339c
commit
38defe1060
16
CHANGELOG.md
16
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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') },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
335
frontend/src/pages/Profile.jsx
Normal file
335
frontend/src/pages/Profile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user