From 9b01c34e77e1cefa261d225d77cc95845c876f9c Mon Sep 17 00:00:00 2001 From: Imran Farhat Date: Sat, 20 Jun 2026 16:16:51 +0530 Subject: [PATCH] Fix blank profile page by implementing UserProfile component and API endpoint Problem: - /dashboard/profile route had no component assigned, page was blank - last_login was never updated on login, always showing null Fix: - Create UserProfile React component with avatar, stats, edit mode, and change password dialog - Add UserProfileSerializer exposing id, username, email, first_name, last_name, date_joined, last_login - Add UserProfileUpdateSerializer for PATCH requests - Add UserProfileView REST endpoint at /api/auth/user/profile/ - Wire /dashboard/profile route to UserProfile component - Add autoComplete=new-password to password fields to prevent autofill - Merge PATCH response into existing profile state to preserve read-only fields like date_joined after editing - Update last_login on token auth to reflect actual login time --- .../src/components/Dashboard/UserProfile.js | 397 ++++++++++++++++++ eda-frontend/src/pages/Dashboard.js | 3 +- esim-cloud-backend/authAPI/serializers.py | 14 + esim-cloud-backend/authAPI/urls.py | 6 +- esim-cloud-backend/authAPI/views.py | 21 + 5 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 eda-frontend/src/components/Dashboard/UserProfile.js diff --git a/eda-frontend/src/components/Dashboard/UserProfile.js b/eda-frontend/src/components/Dashboard/UserProfile.js new file mode 100644 index 000000000..9b19174c5 --- /dev/null +++ b/eda-frontend/src/components/Dashboard/UserProfile.js @@ -0,0 +1,397 @@ +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { makeStyles } from '@material-ui/core/styles' +import { + Avatar, + Button, + Card, + CardContent, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + TextField, + Typography, + Snackbar +} from '@material-ui/core' +import MuiAlert from '@material-ui/lab/Alert' +import api from '../../utils/Api' + +const useStyles = makeStyles((theme) => ({ + root: { + padding: theme.spacing(3) + }, + card: { + maxWidth: 620, + margin: '0 auto', + marginTop: theme.spacing(4) + }, + avatar: { + width: theme.spacing(10), + height: theme.spacing(10), + margin: '0 auto', + marginBottom: theme.spacing(2), + backgroundColor: theme.palette.primary.main, + fontSize: '2rem' + }, + center: { + textAlign: 'center' + }, + chip: { + margin: theme.spacing(0.5) + }, + statsRow: { + display: 'flex', + justifyContent: 'center', + gap: theme.spacing(3), + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1) + }, + statBox: { + textAlign: 'center', + padding: theme.spacing(1.5, 3), + backgroundColor: theme.palette.grey[100], + borderRadius: theme.shape.borderRadius + }, + statNumber: { + fontSize: '1.8rem', + fontWeight: 'bold', + color: theme.palette.primary.main + }, + sectionTitle: { + fontWeight: 600, + marginBottom: theme.spacing(2), + marginTop: theme.spacing(1) + }, + field: { + marginBottom: theme.spacing(2) + }, + btnRow: { + display: 'flex', + gap: theme.spacing(2), + marginTop: theme.spacing(2), + justifyContent: 'flex-end' + } +})) + +function formatDate (dateStr) { + if (!dateStr) return 'Never' + const d = new Date(dateStr) + return d.toLocaleDateString('en-IN', { + day: 'numeric', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }) +} + +export default function UserProfile () { + const classes = useStyles() + const auth = useSelector(state => state.authReducer) + + const [profile, setProfile] = useState(null) + const [schematicCount, setSchematicCount] = useState(0) + const [simulationCount] = useState(0) + const [editMode, setEditMode] = useState(false) + const [formData, setFormData] = useState({ + username: '', email: '', first_name: '', last_name: '' + }) + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }) + const [passwordDialog, setPasswordDialog] = useState(false) + const [passwordForm, setPasswordForm] = useState({ current_password: '', new_password: '', re_new_password: '' }) + const [passwordErrors, setPasswordErrors] = useState({}) + + const getConfig = () => { + const token = localStorage.getItem('esim_auth_token') + const config = { headers: { 'Content-Type': 'application/json' } } + if (token) config.headers.Authorization = `Token ${token}` + return config + } + + useEffect(() => { + const config = getConfig() + api.get('auth/user/profile/', config) + .then(res => { + setProfile(prev => ({ ...prev, ...res.data })) + setFormData({ + username: res.data.username || '', + email: res.data.email || '', + first_name: res.data.first_name || '', + last_name: res.data.last_name || '' + }) + }) + .catch(err => console.log(err)) + + api.get('save/list', config) + .then(res => setSchematicCount(res.data.length)) + .catch(err => console.log(err)) + }, []) + + const handleSave = () => { + const config = getConfig() + api.patch('auth/user/profile/', formData, config) + .then(res => { + setProfile(prev => ({ ...prev, ...res.data })) + setEditMode(false) + setSnackbar({ open: true, message: 'Profile updated successfully', severity: 'success' }) + }) + .catch(() => { + setSnackbar({ open: true, message: 'Failed to update profile', severity: 'error' }) + }) + } + + if (!auth.user || !profile) { + return ( +
+ Loading profile... +
+ ) + } + + return ( +
+ + + + {/* Avatar and name */} +
+ + {profile.username.charAt(0).toUpperCase()} + + + {profile.first_name || profile.last_name + ? `${profile.first_name} ${profile.last_name}`.trim() + : profile.username} + + + {profile.email || 'No email set'} + + {auth.roles && ( +
+ {auth.roles.is_type_contributor && ( + + )} + {auth.roles.is_type_reviewer && ( + + )} +
+ )} +
+ + + + {/* Stats */} +
+
+
{schematicCount}
+ Schematics +
+
+
{simulationCount}
+ Simulations +
+
+ + + + {/* Account info */} + + Account Information + + + {!editMode ? ( + + + Username + {profile.username} + + + Email + {profile.email || 'Not set'} + + + First Name + {profile.first_name || 'Not set'} + + + Last Name + {profile.last_name || 'Not set'} + + + Member Since + {formatDate(profile.date_joined)} + + + Last Login + {formatDate(profile.last_login)} + + + ) : ( + + + setFormData({ ...formData, username: e.target.value })} + fullWidth size="small" variant="outlined" + className={classes.field} + /> + + + setFormData({ ...formData, email: e.target.value })} + fullWidth size="small" variant="outlined" + className={classes.field} + /> + + + setFormData({ ...formData, first_name: e.target.value })} + fullWidth size="small" variant="outlined" + className={classes.field} + /> + + + setFormData({ ...formData, last_name: e.target.value })} + fullWidth size="small" variant="outlined" + className={classes.field} + /> + + + )} + +
+ {!editMode ? ( + <> + + + + ) : ( + <> + + + + )} +
+ +
+
+ + + + setPasswordDialog(false)} maxWidth="sm" fullWidth> + Change Password + + setPasswordForm({ ...passwordForm, current_password: e.target.value })} + error={!!passwordErrors.current_password} + helperText={passwordErrors.current_password} + /> + setPasswordForm({ ...passwordForm, new_password: e.target.value })} + error={!!passwordErrors.new_password} + helperText={passwordErrors.new_password} + /> + setPasswordForm({ ...passwordForm, re_new_password: e.target.value })} + error={!!passwordErrors.re_new_password} + helperText={passwordErrors.re_new_password} + /> + + + + + + + + setSnackbar({ ...snackbar, open: false })} + > + + {snackbar.message} + + +
+ ) +} diff --git a/eda-frontend/src/pages/Dashboard.js b/eda-frontend/src/pages/Dashboard.js index 9bd75bb40..995279cd5 100644 --- a/eda-frontend/src/pages/Dashboard.js +++ b/eda-frontend/src/pages/Dashboard.js @@ -11,6 +11,7 @@ import DashboardHome from '../components/Dashboard/DashboardHome' import SchematicsList from '../components/Dashboard/SchematicsList' import DashboardOtherProjects from '../components/Dashboard/DashboardOtherProjects' import api from '../utils/Api' +import UserProfile from '../components/Dashboard/UserProfile' const useStyles = makeStyles((theme) => ({ root: { @@ -62,7 +63,7 @@ export default function Dashboard () { {/* Subroutes under dashboard section */} {ltiDetails !== null && } /> - + [\w-]+)/(?P[\w-]+)/$', authAPI_views.activate_user), - url(r'user/token/', authAPI_views.CustomTokenCreateView.as_view()) + url(r'user/token/', authAPI_views.CustomTokenCreateView.as_view()), + url(r'^user/profile/$', authAPI_views.UserProfileView.as_view(), + name='user-profile'), ] diff --git a/esim-cloud-backend/authAPI/views.py b/esim-cloud-backend/authAPI/views.py index eccd536d1..16ab79a30 100644 --- a/esim-cloud-backend/authAPI/views.py +++ b/esim-cloud-backend/authAPI/views.py @@ -88,6 +88,9 @@ class CustomTokenCreateView(utils.ActionViewMixin, generics.GenericAPIView): def _action(self, serializer): from rest_framework.authtoken.models import Token token, created = Token.objects.get_or_create(user=serializer.user) + from django.utils import timezone + serializer.user.last_login = timezone.now() + serializer.user.save(update_fields=["last_login"]) data = { 'auth_token': token.key, 'user_id': serializer.user.id @@ -95,3 +98,21 @@ def _action(self, serializer): return Response( data=data, status=status.HTTP_200_OK ) + +from authAPI.serializers import UserProfileSerializer, UserProfileUpdateSerializer + + +class UserProfileView(generics.RetrieveUpdateAPIView): + """ + GET: Returns full user profile including date_joined, last_login, name + PATCH: Update username, email, first_name, last_name + """ + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + if self.request.method in ['PUT', 'PATCH']: + return UserProfileUpdateSerializer + return UserProfileSerializer + + def get_object(self): + return self.request.user