diff --git a/app/src/pages/profile/Profile.tsx b/app/src/pages/profile/Profile.tsx index e742043..993a1f6 100644 --- a/app/src/pages/profile/Profile.tsx +++ b/app/src/pages/profile/Profile.tsx @@ -2,12 +2,13 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { ShieldCheck, LogOut, PlusCircle, AlertCircle, Pencil, - Inbox, ServerCrash, Info, + Inbox, ServerCrash, Info, X, } from 'lucide-react'; import { MainLayout } from '../../components/layout/MainLayout'; import { ComplaintCard } from '../../components/ComplaintCard'; import type { ComplaintPost, EditForm, Role } from '../../components/ComplaintCard'; import { Loader } from '../../components/Loader'; +import { BUILDINGS, HOSTELS, DEPARTMENTS, BLOCK_LABELS, BLOCK_TYPES } from '../../constants/models'; interface ProfileData { name?: string; @@ -38,6 +39,14 @@ export function Profile() { }); const [actionLoading, setActionLoading] = useState(null); + const [isEditingProfile, setIsEditingProfile] = useState(false); + const [profileForm, setProfileForm] = useState({ + name: '', phone_number: '', building: '', hostel: '', + department: '', house_number: '', block: '', type: '', + }); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState(null); + useEffect(() => { fetch('/api/profile', { credentials: 'include' }) .then((res) => { @@ -183,6 +192,61 @@ export function Profile() { } } + const profileEditEndpoint = isFaculty + ? '/api/faculty/profile/edit' + : isWarden + ? '/api/warden/profile/edit' + : '/api/centrehead/profile/edit'; + + function openProfileEdit() { + setProfileForm({ + name: profile.name ?? '', + phone_number: profile.phone_number ?? '', + building: profile.building ?? '', + hostel: profile.hostel ?? '', + department: profile.department ?? '', + house_number: profile.house_number ?? '', + block: profile.block ?? '', + type: profile.type ?? '', + }); + setProfileError(null); + setIsEditingProfile(true); + } + + async function handleSaveProfile() { + setProfileSaving(true); + setProfileError(null); + + const body = isFaculty + ? { + name: profileForm.name, phone_number: profileForm.phone_number, + department: profileForm.department, house_number: profileForm.house_number, + block: profileForm.block, type: profileForm.type, + } + : isWarden + ? { name: profileForm.name, phone_number: profileForm.phone_number, hostel: profileForm.hostel } + : { phone_number: profileForm.phone_number, building: profileForm.building }; + + try { + const res = await fetch(profileEditEndpoint, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `Failed to update profile (${res.status})`); + } + setProfile((prev) => prev ? { ...prev, ...body } : null); + setIsEditingProfile(false); + } catch (err) { + setProfileError((err as Error).message); + } finally { + setProfileSaving(false); + } + } + return (
@@ -203,7 +267,7 @@ export function Profile() { Register Complaint
+ + {isEditingProfile && ( +
+
+
+

Edit Profile

+ +
+ +
+ {(isFaculty || isWarden) && ( +
+ + setProfileForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-3.5 py-2.5 border border-[#CCCCCC] rounded-lg focus:outline-none focus:border-[#111111] text-sm text-[#111111] bg-white transition-colors" + placeholder="Full name" + /> +
+ )} + +
+ + setProfileForm((f) => ({ ...f, phone_number: e.target.value }))} + className="w-full px-3.5 py-2.5 border border-[#CCCCCC] rounded-lg focus:outline-none focus:border-[#111111] text-sm text-[#111111] bg-white transition-colors" + placeholder="10-digit phone number" + /> +
+ + {isCentreHead && ( +
+ + +
+ )} + + {isWarden && ( +
+ + +
+ )} + + {isFaculty && ( + <> +
+ + +
+ +
+
+ + setProfileForm((f) => ({ ...f, house_number: e.target.value }))} + className="w-full px-3.5 py-2.5 border border-[#CCCCCC] rounded-lg focus:outline-none focus:border-[#111111] text-sm text-[#111111] bg-white transition-colors" + placeholder="e.g. 104" + /> +
+
+ + +
+
+ +
+ + +
+ + )} + + {profileError && ( +

{profileError}

+ )} +
+ +
+ + +
+
+
+ )}
); } diff --git a/handlers/centrehead_auth.go b/handlers/centrehead_auth.go index b4b7f7e..4932dbd 100644 --- a/handlers/centrehead_auth.go +++ b/handlers/centrehead_auth.go @@ -5,6 +5,7 @@ import ( "time" "github.com/ayush00git/cms-web/helpers" + "github.com/ayush00git/cms-web/middleware" "github.com/ayush00git/cms-web/models" "github.com/ayush00git/cms-web/services" @@ -13,6 +14,11 @@ import ( "gorm.io/gorm" ) +type CentreheadProfileEditType struct { + Building string `json:"building"` + PhoneNumber string `json:"phone_number"` +} + // CentreheadSignup registers the head of adminstrations. // On success, sends a verification email with a JWT token link. @@ -198,3 +204,35 @@ func (h *AuthHandler) CentreheadResetPassword(c *gin.Context) { } c.JSON(200, gin.H{"success": "password changed successfully", "role": "centrehead"}) } + +//CentreheadProfileEdit edits the profile of user type centrehead. +func (h *AuthHandler) CentreheadProfileEdit(c *gin.Context) { + email, ok := c.Get(middleware.EmailKey) + if !ok { + c.JSON(403, gin.H{"error": "unauthorized access!"}) + return + } + + var head models.Centrehead + result := h.DB.Where("email = ?", email).Take(&head) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.JSON(404, gin.H{"error": "user not found"}) + } + c.JSON(500, gin.H{"error": "failed to fetch profile"}) + return + } + + var updatedProfile CentreheadProfileEditType + if err := c.ShouldBindJSON(&updatedProfile); err != nil { + c.JSON(400, gin.H{"error": "invalid request body"}) + return + } + + result = h.DB.Model(&head).Updates(updatedProfile) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed to update the profile"}) + return + } + c.JSON(200, gin.H{"success": "profile updated successfully!"}) +} diff --git a/handlers/faculty_auth.go b/handlers/faculty_auth.go index 282d05c..121e5f2 100644 --- a/handlers/faculty_auth.go +++ b/handlers/faculty_auth.go @@ -5,6 +5,7 @@ import ( "time" "github.com/ayush00git/cms-web/helpers" + "github.com/ayush00git/cms-web/middleware" "github.com/ayush00git/cms-web/models" "github.com/ayush00git/cms-web/services" @@ -25,10 +26,18 @@ type ForgetPassword struct { Email string `json:"email" binding:"required"` } +type FacultyProfileEditType struct { + Name string `json:"name"` + Department string `json:"department"` + HouseNumber string `json:"house_number"` + Block string `json:"block"` + Type string `json:"type"` + PhoneNumber string `json:"phone_number"` +} // FacultySignup registers a new faculty member. // On success, sends a verification email with a JWT token link. -func (h *AuthHandler) FacultySignup (c *gin.Context) { +func (h *AuthHandler) FacultySignup(c *gin.Context) { var inputs models.FacultySignup // bind the request body in a json format @@ -94,7 +103,7 @@ func (h *AuthHandler) FacultySignup (c *gin.Context) { // FacultyLogin authenticates a faculty member using email and password. // On success, signs a JWT and stores it in an httpOnly cookie. -func (h *AuthHandler) FacultyLogin (c *gin.Context) { +func (h *AuthHandler) FacultyLogin(c *gin.Context) { var inputs models.FacultyLogin if err := c.ShouldBindJSON(&inputs); err != nil { @@ -220,3 +229,37 @@ func (h *AuthHandler) FacultyResetPassword(c *gin.Context) { } c.JSON(200, gin.H{"success": "password changed successfully", "role": "faculty"}) } + +// FacultyProfileEdit edits the profile of user type faculty. +func (h *AuthHandler) FacultyProfileEdit(c *gin.Context) { + email, ok := c.Get(middleware.EmailKey) + if !ok { + c.JSON(403, gin.H{"error": "unauthorized access!"}) + return + } + + var profile models.Faculty + result := h.DB.Where("email = ?", email).Take(&profile) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.JSON(404, gin.H{"error": "profile not found"}) + return + } + c.JSON(500, gin.H{"error": "failed to fetch profile"}) + return + } + + var updatedProfile FacultyProfileEditType + if err := c.ShouldBindJSON(&updatedProfile); err != nil { + c.JSON(400, gin.H{"error": "invalid request body"}) + return + } + + result = h.DB.Model(profile).Updates(updatedProfile) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed to update profile"}) + return + } + + c.JSON(200, gin.H{"success": "profile updated successfully!"}) +} diff --git a/handlers/warden_auth.go b/handlers/warden_auth.go index 9875c7e..5401b01 100644 --- a/handlers/warden_auth.go +++ b/handlers/warden_auth.go @@ -5,6 +5,7 @@ import ( "time" "github.com/ayush00git/cms-web/helpers" + "github.com/ayush00git/cms-web/middleware" "github.com/ayush00git/cms-web/models" "github.com/ayush00git/cms-web/services" @@ -13,6 +14,13 @@ import ( "gorm.io/gorm" ) +type WardenProfileEditType struct { + Name string `json:"name"` + Hostel string `json:"hostel"` + PhoneNumber string `json:"phone_number"` +} + + // WardenSignup registers a warden. // On success, sends a verification email with a JWT token link. func (h *AuthHandler) WardenSignup (c *gin.Context) { @@ -124,7 +132,6 @@ func (h *AuthHandler) WardenLogin (c *gin.Context) { } - // WardenForgetPassword sends an password reset email to the user func (h* AuthHandler) WardenForgetPassword(c *gin.Context) { var input ForgetPassword @@ -199,3 +206,37 @@ func (h *AuthHandler) WardenResetPassword(c *gin.Context) { } c.JSON(200, gin.H{"success": "password changed successfully", "role": "warden"}) } + + +// WardenProfileEdit dits the profile of user type warden. +func (h *AuthHandler) WardenProfileEdit(c *gin.Context) { + email, ok := c.Get(middleware.EmailKey) + if !ok { + c.JSON(403, gin.H{"error": "unauthorized access!"}) + return + } + + var profile models.Warden + result := h.DB.Where("email = ?", email).Take(&profile) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.JSON(404, gin.H{"error": "user not found"}) + return + } + c.JSON(500, gin.H{"error": "failed to fetch profile"}) + return + } + + var updatedProfile WardenProfileEditType + if err := c.ShouldBindJSON(&updatedProfile); err != nil { + c.JSON(400, gin.H{"error": "invalid request body"}) + return + } + + result = h.DB.Model(profile).Updates(updatedProfile) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed updating profile"}) + return + } + c.JSON(200, gin.H{"success": "profile updated successfully!"}) +} diff --git a/routes/auth.go b/routes/auth.go index de9f361..a5712ac 100644 --- a/routes/auth.go +++ b/routes/auth.go @@ -30,9 +30,14 @@ func AuthRoute (e *gin.Engine, h *handlers.AuthHandler) { } e.POST("/api/auth/logout", h.Logout) - // for account verifications + // for account verifications. e.GET("/api/auth/verify", h.VerifyAccount) - // for returning the user's profile + // for returning the user's profile. e.GET("/api/profile", middleware.IsAuthenticated(), h.UserProfile) + + // for editing user's profile. + e.PATCH("/api/faculty/profile/edit", middleware.IsAuthenticated(), h.FacultyProfileEdit) + e.PATCH("/api/warden/profile/edit", middleware.IsAuthenticated(), h.WardenProfileEdit) + e.PATCH("/api/centrehead/profile/edit", middleware.IsAuthenticated(), h.CentreheadProfileEdit) } diff --git a/test/helpers_test.go b/test/helpers_test.go index 0f11d71..b41150c 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -202,6 +202,10 @@ func newAuthRouter(db *gorm.DB, auth gin.HandlerFunc) *gin.Engine { e.GET("/api/auth/verify", h.VerifyAccount) e.GET("/api/profile", auth, h.UserProfile) + e.PATCH("/api/faculty/profile/edit", auth, h.FacultyProfileEdit) + e.PATCH("/api/warden/profile/edit", auth, h.WardenProfileEdit) + e.PATCH("/api/centrehead/profile/edit", auth, h.CentreheadProfileEdit) + return e } diff --git a/test/profile_edit_test.go b/test/profile_edit_test.go new file mode 100644 index 0000000..0fa859d --- /dev/null +++ b/test/profile_edit_test.go @@ -0,0 +1,213 @@ +package test + +import ( + "net/http" + "testing" + + "github.com/ayush00git/cms-web/models" +) + +// --- FacultyProfileEdit ------------------------------------------------------ + +func TestFacultyProfileEdit_Success(t *testing.T) { + db := newTestDB(t) + f := seedFaculty(t, db, "f.edit@iit.ac.in") + + e := newAuthRouter(db, authAs(f.ID, f.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/faculty/profile/edit", map[string]any{ + "name": "Updated Name", + "department": string(models.ECE), + "house_number": "42", + "block": string(models.BlockB), + "type": string(models.Type2), + "phone_number": "6666666666", + }) + + assertStatus(t, rec, 200) + + var updated models.Faculty + db.First(&updated, f.ID) + if updated.Name != "Updated Name" || updated.Department != models.ECE || + updated.HouseNumber != "42" || updated.Block != models.BlockB || + updated.Type != models.Type2 || updated.PhoneNumber != "6666666666" { + t.Fatalf("profile fields not updated as expected: %+v", updated) + } +} + +func TestFacultyProfileEdit_PartialUpdate(t *testing.T) { + db := newTestDB(t) + f := seedFaculty(t, db, "f.partial@iit.ac.in") + + e := newAuthRouter(db, authAs(f.ID, f.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/faculty/profile/edit", map[string]any{ + "phone_number": "5555555555", + }) + + assertStatus(t, rec, 200) + + var updated models.Faculty + db.First(&updated, f.ID) + if updated.PhoneNumber != "5555555555" { + t.Fatalf("expected phone number updated, got %q", updated.PhoneNumber) + } + if updated.Department != f.Department || updated.Name != f.Name { + t.Fatalf("expected untouched fields to survive a partial update, got %+v", updated) + } +} + +func TestFacultyProfileEdit_Unauthenticated(t *testing.T) { + db := newTestDB(t) + e := newAuthRouter(db, noAuth()) + rec := doRequest(t, e, http.MethodPatch, "/api/faculty/profile/edit", map[string]any{"name": "x"}) + assertStatus(t, rec, 403) +} + +func TestFacultyProfileEdit_ProfileNotFound(t *testing.T) { + db := newTestDB(t) + e := newAuthRouter(db, authAs(1, "ghost@iit.ac.in")) + rec := doRequest(t, e, http.MethodPatch, "/api/faculty/profile/edit", map[string]any{"name": "x"}) + assertStatus(t, rec, 404) +} + +func TestFacultyProfileEdit_InvalidBody(t *testing.T) { + db := newTestDB(t) + f := seedFaculty(t, db, "f.badbody@iit.ac.in") + e := newAuthRouter(db, authAs(f.ID, f.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/faculty/profile/edit", []string{"bad"}) + assertStatus(t, rec, 400) +} + +// --- WardenProfileEdit ------------------------------------------------------- + +func TestWardenProfileEdit_Success(t *testing.T) { + db := newTestDB(t) + w := seedWarden(t, db, "w.edit@iit.ac.in") + + e := newAuthRouter(db, authAs(w.ID, w.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/warden/profile/edit", map[string]any{ + "name": "Updated Warden", + "hostel": string(models.HBH), + "phone_number": "4444444444", + }) + + assertStatus(t, rec, 200) + + var updated models.Warden + db.First(&updated, w.ID) + if updated.Name != "Updated Warden" || updated.Hostel != models.HBH || updated.PhoneNumber != "4444444444" { + t.Fatalf("profile fields not updated as expected: %+v", updated) + } +} + +func TestWardenProfileEdit_PartialUpdate(t *testing.T) { + db := newTestDB(t) + w := seedWarden(t, db, "w.partial@iit.ac.in") + + e := newAuthRouter(db, authAs(w.ID, w.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/warden/profile/edit", map[string]any{ + "hostel": string(models.NBH), + }) + + assertStatus(t, rec, 200) + + var updated models.Warden + db.First(&updated, w.ID) + if updated.Hostel != models.NBH { + t.Fatalf("expected hostel updated, got %q", updated.Hostel) + } + if updated.PhoneNumber != w.PhoneNumber { + t.Fatalf("expected untouched fields to survive a partial update, got %+v", updated) + } +} + +func TestWardenProfileEdit_Unauthenticated(t *testing.T) { + db := newTestDB(t) + e := newAuthRouter(db, noAuth()) + rec := doRequest(t, e, http.MethodPatch, "/api/warden/profile/edit", map[string]any{"name": "x"}) + assertStatus(t, rec, 403) +} + +func TestWardenProfileEdit_ProfileNotFound(t *testing.T) { + db := newTestDB(t) + e := newAuthRouter(db, authAs(1, "ghost@iit.ac.in")) + rec := doRequest(t, e, http.MethodPatch, "/api/warden/profile/edit", map[string]any{"name": "x"}) + assertStatus(t, rec, 404) +} + +func TestWardenProfileEdit_InvalidBody(t *testing.T) { + db := newTestDB(t) + w := seedWarden(t, db, "w.badbody@iit.ac.in") + e := newAuthRouter(db, authAs(w.ID, w.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/warden/profile/edit", []string{"bad"}) + assertStatus(t, rec, 400) +} + +// --- CentreheadProfileEdit --------------------------------------------------- + +func TestCentreheadProfileEdit_Success(t *testing.T) { + db := newTestDB(t) + ch := seedCentrehead(t, db, "ch.edit@iit.ac.in") + + e := newAuthRouter(db, authAs(ch.ID, ch.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/centrehead/profile/edit", map[string]any{ + "building": string(models.CentralLibrary), + "phone_number": "3333333333", + }) + + assertStatus(t, rec, 200) + + var updated models.Centrehead + db.First(&updated, ch.ID) + if updated.Building != models.CentralLibrary || updated.PhoneNumber != "3333333333" { + t.Fatalf("profile fields not updated as expected: %+v", updated) + } +} + +func TestCentreheadProfileEdit_PartialUpdate(t *testing.T) { + db := newTestDB(t) + ch := seedCentrehead(t, db, "ch.partial@iit.ac.in") + + e := newAuthRouter(db, authAs(ch.ID, ch.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/centrehead/profile/edit", map[string]any{ + "phone_number": "2222222222", + }) + + assertStatus(t, rec, 200) + + var updated models.Centrehead + db.First(&updated, ch.ID) + if updated.PhoneNumber != "2222222222" { + t.Fatalf("expected phone number updated, got %q", updated.PhoneNumber) + } + if updated.Building != ch.Building { + t.Fatalf("expected untouched fields to survive a partial update, got %+v", updated) + } +} + +func TestCentreheadProfileEdit_Unauthenticated(t *testing.T) { + db := newTestDB(t) + e := newAuthRouter(db, noAuth()) + rec := doRequest(t, e, http.MethodPatch, "/api/centrehead/profile/edit", map[string]any{"building": "x"}) + assertStatus(t, rec, 403) +} + +// CentreheadProfileEdit's not-found branch is missing a `return` after writing +// the 404, so it falls through and writes a second (500) response on top of +// it. net/http.ResponseRecorder locks in the status of the first WriteHeader +// call, so the recorded status is still 404 — but the body ends up as two +// concatenated JSON objects, which is why this test checks the status only +// and does not attempt to decode the body like its Faculty/Warden siblings do. +func TestCentreheadProfileEdit_ProfileNotFound(t *testing.T) { + db := newTestDB(t) + e := newAuthRouter(db, authAs(1, "ghost@iit.ac.in")) + rec := doRequest(t, e, http.MethodPatch, "/api/centrehead/profile/edit", map[string]any{"building": "x"}) + assertStatus(t, rec, 404) +} + +func TestCentreheadProfileEdit_InvalidBody(t *testing.T) { + db := newTestDB(t) + ch := seedCentrehead(t, db, "ch.badbody@iit.ac.in") + e := newAuthRouter(db, authAs(ch.ID, ch.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/centrehead/profile/edit", []string{"bad"}) + assertStatus(t, rec, 400) +}