From ff2818372db46cdbc8764ae90218aaa8124818a9 Mon Sep 17 00:00:00 2001 From: ayush00git Date: Tue, 1 Sep 2026 19:43:25 +0530 Subject: [PATCH 1/4] feat: add access mail sending service --- app/src/App.tsx | 2 + app/src/pages/auth/AdminAccess.tsx | 116 +++++++++++++++++++++++++++++ app/src/pages/auth/StaffLogin.tsx | 52 ++----------- handlers/admin_auth.go | 42 +++++++++-- handlers/admin_comment.go | 4 + models/admin_auth.go | 1 - routes/admin.go | 1 + services/email.go | 28 +++++++ test/admin_auth_test.go | 104 +++++++++++++++++++++----- test/helpers_test.go | 9 ++- 10 files changed, 283 insertions(+), 76 deletions(-) create mode 100644 app/src/pages/auth/AdminAccess.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 3325a41..3c28623 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ import { WardenForgotPassword } from './pages/auth/WardenForgotPassword'; import { CentreHeadForgotPassword } from './pages/auth/CentreHeadForgotPassword'; import { AccountResetPass } from './pages/auth/AccountResetPass'; import { StaffLogin } from './pages/auth/StaffLogin'; +import { AdminAccess } from './pages/auth/AdminAccess'; import { VerifyAccount } from './pages/auth/VerifyAccount'; import { FacultyPost } from './pages/post/FacultyPost'; import { WardenPost } from './pages/post/WardenPost'; @@ -42,6 +43,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/src/pages/auth/AdminAccess.tsx b/app/src/pages/auth/AdminAccess.tsx new file mode 100644 index 0000000..11bbfe8 --- /dev/null +++ b/app/src/pages/auth/AdminAccess.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useSearchParams, useNavigate } from 'react-router-dom'; +import { MainLayout } from '../../components/layout/MainLayout'; +import { Loader } from '../../components/Loader'; +import { useAuth } from '../../context/auth-context'; + +function dashboardFor(position: string): string { + if (position.startsWith('XEN')) return '/admin/xen'; + if (position.startsWith('AE')) return '/admin/ae'; + if (position.startsWith('JE')) return '/admin/je'; + return '/'; +} + +type AccessStatus = 'loading' | 'error' | 'no-token'; + +// AdminAccess is the page the emailed login link lands on. It completes the +// passwordless login on mount: exchanges the link token for a session cookie +// and jumps straight to the admin's dashboard. +export function AdminAccess() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { refetch } = useAuth(); + const token = searchParams.get('token'); + + const [status, setStatus] = useState(token ? 'loading' : 'no-token'); + const [message, setMessage] = useState(token ? '' : 'No login token found in the link.'); + + const controllerRef = useRef(null); + + useEffect(() => { + if (!token) { + return; + } + + // cancel any previous in-flight request (StrictMode double-invoke) + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + + fetch(`/api/auth/admin/access?token=${encodeURIComponent(token)}`, { + method: 'GET', + credentials: 'include', + signal: controller.signal, + }) + .then(async (res) => { + const data = await res.json(); + + if (res.ok) { + const dest = dashboardFor(data.position ?? ''); + if (dest === '/') { + setStatus('error'); + setMessage(`Unknown position "${data.position}" — contact admin.`); + return; + } + refetch(); + navigate(dest, { replace: true }); + } else { + setStatus('error'); + setMessage(data.error || 'Login failed. The link may be expired or invalid.'); + } + }) + .catch((err) => { + if ((err as Error).name === 'AbortError') return; // cancelled — ignore + setStatus('error'); + setMessage('Failed to connect to the server. Please try again.'); + }); + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // run once on mount — token never changes after the page loads + + return ( + +
+ + {/* Loading */} + {status === 'loading' && ( +
+ +

Logging you in, please wait…

+
+ )} + + {/* Error / No token */} + {(status === 'error' || status === 'no-token') && ( +
+
+ + + +
+
+

Login Failed

+

{message}

+
+ + Request a New Link + +
+ )} + +
+
+ ); +} diff --git a/app/src/pages/auth/StaffLogin.tsx b/app/src/pages/auth/StaffLogin.tsx index 10f3de9..74aa9dc 100644 --- a/app/src/pages/auth/StaffLogin.tsx +++ b/app/src/pages/auth/StaffLogin.tsx @@ -1,21 +1,10 @@ import React, { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { Eye, EyeOff } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { MainLayout } from '../../components/layout/MainLayout'; import { Loader } from '../../components/Loader'; -function dashboardFor(position: string): string { - if (position.startsWith('XEN')) return '/admin/xen'; - if (position.startsWith('AE')) return '/admin/ae'; - if (position.startsWith('JE')) return '/admin/je'; - return '/'; -} - export function StaffLogin() { - const navigate = useNavigate(); const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [status, setStatus] = useState<'success' | 'error' | null>(null); const [message, setMessage] = useState(''); @@ -30,20 +19,15 @@ export function StaffLogin() { const response = await fetch('/api/auth/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email }), credentials: 'include', }); const data = await response.json(); if (response.ok) { - const dest = dashboardFor(data.position ?? ''); - if (dest === '/') { - setStatus('error'); - setMessage(`Unknown position "${data.position}" — contact admin.`); - } else { - navigate(dest); - } + setStatus('success'); + setMessage(`A login link has been sent to ${email}. Check your inbox to continue.`); } else { setStatus('error'); const errorMsg = data.error || data.email || Object.values(data)[0] || 'An error occurred'; @@ -68,7 +52,7 @@ export function StaffLogin() {

Staff Login

-

Secure portal for XEN / AE / JE.

+

Secure portal for XEN / AE / JE. We'll email you a login link.

@@ -95,7 +79,7 @@ export function StaffLogin() {
-

Credentials

+

Passwordless Login

@@ -108,28 +92,6 @@ export function StaffLogin() { required />
- -
- -
- setPassword(e.target.value)} - className={`${inputCls} pr-10`} - placeholder="••••••••" - required - /> - -
-
@@ -142,7 +104,7 @@ export function StaffLogin() { className={`inline-flex items-center gap-2 bg-[#16a34a] hover:bg-[#15803d] text-white font-semibold py-2.5 px-8 rounded-lg transition-colors duration-200 text-sm active:scale-[0.98] ${loading ? 'opacity-70 cursor-not-allowed' : 'cursor-pointer'}`} > {loading && } - {loading ? 'Logging in…' : 'Login to Portal'} + {loading ? 'Sending link…' : 'Mail me a login link'} diff --git a/handlers/admin_auth.go b/handlers/admin_auth.go index 97cb8e1..db35969 100644 --- a/handlers/admin_auth.go +++ b/handlers/admin_auth.go @@ -5,8 +5,8 @@ import ( "github.com/ayush00git/cms-web/helpers" "github.com/ayush00git/cms-web/models" + "github.com/ayush00git/cms-web/services" "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -33,8 +33,9 @@ import ( // Note that for admins as they are pre-set to the service the // isVerified field is always by default true. -// AdminLogin authenticates the admin using email and password. -// On success, signs a JWT and stores it in an httpOnly cookie. +// AdminLogin authenticates the admin using only email. It mails a signed +// access link to the admin; no cookie is set here, the session starts +// only when the admin clicks the link (handled by AdminAccess). func (h *AdminHandler) AdminLogin (c *gin.Context) { var inputs models.AdminLogin @@ -47,7 +48,7 @@ func (h *AdminHandler) AdminLogin (c *gin.Context) { result := h.DB.Where("email = ?", inputs.Email).Take(&admin) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { - c.JSON(404, gin.H{"error": "admin record not found"}) + c.JSON(403, gin.H{"error": "admin record not found"}) return } c.JSON(500, gin.H{"error": "internal server error"}) @@ -59,9 +60,36 @@ func (h *AdminHandler) AdminLogin (c *gin.Context) { return } - err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(inputs.Password)) + sendAccessMail := h.SendAccessMail + if sendAccessMail == nil { + sendAccessMail = services.SendProfileAccessMailToAdmins + } + if err := sendAccessMail(admin.ID, admin.Email); err != nil { + c.JSON(500, gin.H{"error": "failed to send the access mail"}) + return + } + + c.JSON(200, gin.H{"success": "access mail sent!"}) +} + +// AdminAccess completes the passwordless login. It verifies the token from +// the emailed access link, signs a fresh session JWT and stores it in an +// httpOnly cookie. +func (h *AdminHandler) AdminAccess (c *gin.Context) { + claims, err := helpers.VerifyToken(c.Query("token")) if err != nil { - c.JSON(401, gin.H{"error": "incorrect password!"}) + c.JSON(401, gin.H{"error": "invalid or expired access link"}) + return + } + + var admin models.Admin + result := h.DB.Where("email = ?", claims.Email).Take(&admin) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.JSON(403, gin.H{"error": "admin record not found"}) + return + } + c.JSON(500, gin.H{"error": "internal server error"}) return } @@ -74,7 +102,7 @@ func (h *AdminHandler) AdminLogin (c *gin.Context) { c.SetCookie( "token", token, - 30 * 24 * 60 * 60, + 7 * 24 * 60 * 60, "/", helpers.GetEnvWithDefault("COOKIE_DOMAIN", "localhost"), false, diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go index daa5b1d..ba97977 100644 --- a/handlers/admin_comment.go +++ b/handlers/admin_comment.go @@ -17,6 +17,10 @@ import ( type AdminHandler struct { DB *gorm.DB + + // SendAccessMail lets tests stub out the real mailer; when nil the + // handler falls back to services.SendProfileAccessMailToAdmins. + SendAccessMail func(adminID uint, email string) error } type CommentType struct { diff --git a/models/admin_auth.go b/models/admin_auth.go index df6d6fc..6ac1324 100644 --- a/models/admin_auth.go +++ b/models/admin_auth.go @@ -25,5 +25,4 @@ type Admin struct { type AdminLogin struct { Email string `json:"email" binding:"required,email,max=255"` - Password string `json:"password" binding:"required,max=72"` } diff --git a/routes/admin.go b/routes/admin.go index bdc4403..be68331 100644 --- a/routes/admin.go +++ b/routes/admin.go @@ -13,6 +13,7 @@ func AdminRoutes (e *gin.Engine, h *handlers.AdminHandler) { adminLoginRateLimiter := middleware.NewRateLimiter(10, 1.0/6.0) e.POST("/api/auth/admin/login", adminLoginRateLimiter.Limit(), h.AdminLogin) + e.GET("/api/auth/admin/access", adminLoginRateLimiter.Limit(), h.AdminAccess) e.GET("/api/admin/comments", middleware.IsAuthenticated(), h.AdminGetComments) e.POST("/api/admin/comment/:type/:id", middleware.IsAuthenticated(), h.AdminPostComment) diff --git a/services/email.go b/services/email.go index c36403d..5578e3a 100644 --- a/services/email.go +++ b/services/email.go @@ -212,3 +212,31 @@ func SendMailToPeopleInThread(emails []string, ignoreEmail string, postURL strin } return nil } + +// SendProfileAccessMailToAdmins mails a signed access link to the admin, +// completing the passwordless login when clicked +func SendProfileAccessMailToAdmins(adminID uint, email string) error { + token, err := helpers.GenerateToken(adminID, email, "admin") + if err != nil { + return err + } + + // create the access url + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + accessURL := fmt.Sprintf(`%s/admin/access?token=%s`, frontendURL, token) + + mail := buildEmailHTML( + "cms: access your account", + "We received a request from you to get in.", + "Log In", + accessURL, + ) + + // sends the email + err = SendMail(email, "Log in to your cms admin account", mail) + if err != nil { + return err + } + log.Printf("admin access mail was sent to %s", email) + return nil +} diff --git a/test/admin_auth_test.go b/test/admin_auth_test.go index 06b770f..72cdaa9 100644 --- a/test/admin_auth_test.go +++ b/test/admin_auth_test.go @@ -1,59 +1,123 @@ package test import ( + "errors" + "fmt" "net/http" "testing" "github.com/ayush00git/cms-web/models" ) -// --- AdminLogin ------------------------------------------------------------- +// --- AdminLogin (sends the access mail) ------------------------------------- -func TestAdminLogin_Success(t *testing.T) { +func TestAdminLogin_SendsAccessMail(t *testing.T) { db := newTestDB(t) admin := seedAdmin(t, db, "admin.login@iit.ac.in", models.TypeXENCivil) - e := newAdminAuthRouter(db) + var gotID uint + var gotEmail string + e := newAdminAuthRouter(db, func(adminID uint, email string) error { + gotID = adminID + gotEmail = email + return nil + }) + rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": admin.Email, - "password": testPassword, + "email": admin.Email, }) assertStatus(t, rec, 200) - out := decodeBody(t, rec) - if out["position"] != string(models.TypeXENCivil) { - t.Fatalf("expected position %s in response, got %v", models.TypeXENCivil, out) + if gotID != admin.ID || gotEmail != admin.Email { + t.Fatalf("expected access mail for (%d, %s), got (%d, %s)", admin.ID, admin.Email, gotID, gotEmail) } - if len(rec.Result().Cookies()) == 0 { - t.Fatalf("expected a token cookie to be set") + // the session must not start until the emailed link is clicked + if len(rec.Result().Cookies()) != 0 { + t.Fatalf("expected no cookie on login, got %v", rec.Result().Cookies()) } } func TestAdminLogin_InvalidBody(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db) + e := newAdminAuthRouter(db, nil) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", []string{"bad"}) assertStatus(t, rec, 400) } func TestAdminLogin_NotFound(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db) + e := newAdminAuthRouter(db, nil) + rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ + "email": "ghost.admin@iit.ac.in", + }) + assertStatus(t, rec, 403) +} + +func TestAdminLogin_Unverified(t *testing.T) { + db := newTestDB(t) + admin := seedAdmin(t, db, "admin.unverified@iit.ac.in", models.TypeAECivil) + if err := db.Model(&admin).Update("is_verified", false).Error; err != nil { + t.Fatalf("failed to unverify admin: %v", err) + } + + e := newAdminAuthRouter(db, nil) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": "ghost.admin@iit.ac.in", - "password": testPassword, + "email": admin.Email, }) - assertStatus(t, rec, 404) + assertStatus(t, rec, 401) } -func TestAdminLogin_WrongPassword(t *testing.T) { +func TestAdminLogin_MailFailure(t *testing.T) { db := newTestDB(t) - admin := seedAdmin(t, db, "admin.wrongpw@iit.ac.in", models.TypeAECivil) + admin := seedAdmin(t, db, "admin.mailfail@iit.ac.in", models.TypeJECivil) - e := newAdminAuthRouter(db) + e := newAdminAuthRouter(db, func(uint, string) error { + return errors.New("smtp is down") + }) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": admin.Email, - "password": "nope", + "email": admin.Email, }) + assertStatus(t, rec, 500) +} + +// --- AdminAccess (completes the passwordless login) ------------------------- + +func TestAdminAccess_Success(t *testing.T) { + db := newTestDB(t) + admin := seedAdmin(t, db, "admin.access@iit.ac.in", models.TypeXENElectrical) + + e := newAdminAuthRouter(db, nil) + token := genToken(t, admin.ID, admin.Email, "admin") + rec := doRequest(t, e, http.MethodGet, fmt.Sprintf("/api/auth/admin/access?token=%s", token), nil) + + assertStatus(t, rec, 200) + out := decodeBody(t, rec) + if out["position"] != string(models.TypeXENElectrical) { + t.Fatalf("expected position %s in response, got %v", models.TypeXENElectrical, out) + } + if len(rec.Result().Cookies()) == 0 { + t.Fatalf("expected a token cookie to be set") + } +} + +func TestAdminAccess_MissingToken(t *testing.T) { + db := newTestDB(t) + e := newAdminAuthRouter(db, nil) + rec := doRequest(t, e, http.MethodGet, "/api/auth/admin/access", nil) assertStatus(t, rec, 401) } + +func TestAdminAccess_InvalidToken(t *testing.T) { + db := newTestDB(t) + e := newAdminAuthRouter(db, nil) + rec := doRequest(t, e, http.MethodGet, "/api/auth/admin/access?token=not-a-jwt", nil) + assertStatus(t, rec, 401) +} + +func TestAdminAccess_AdminNotFound(t *testing.T) { + db := newTestDB(t) + e := newAdminAuthRouter(db, nil) + token := genToken(t, 999, "ghost.admin@iit.ac.in", "admin") + rec := doRequest(t, e, http.MethodGet, fmt.Sprintf("/api/auth/admin/access?token=%s", token), nil) + assertStatus(t, rec, 403) +} diff --git a/test/helpers_test.go b/test/helpers_test.go index b41150c..05e1b6e 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -209,11 +209,14 @@ func newAuthRouter(db *gorm.DB, auth gin.HandlerFunc) *gin.Engine { return e } -// newAdminAuthRouter exposes the admin login route against the AdminHandler. -func newAdminAuthRouter(db *gorm.DB) *gin.Engine { +// newAdminAuthRouter exposes the admin auth routes against the AdminHandler. +// sendAccessMail stubs out the real mailer so tests never dial SMTP; pass nil +// only when the mail path is unreachable in the scenario under test. +func newAdminAuthRouter(db *gorm.DB, sendAccessMail func(adminID uint, email string) error) *gin.Engine { e := gin.New() - h := &handlers.AdminHandler{DB: db} + h := &handlers.AdminHandler{DB: db, SendAccessMail: sendAccessMail} e.POST("/api/auth/admin/login", h.AdminLogin) + e.GET("/api/auth/admin/access", h.AdminAccess) return e } From 11dda1d5b1ca26b1cfc94e16563f13b3d5bdb98b Mon Sep 17 00:00:00 2001 From: ayush00git Date: Tue, 1 Sep 2026 19:52:09 +0530 Subject: [PATCH 2/4] fix: remove the demo dev/setup for SMTP --- handlers/admin_auth.go | 6 +--- handlers/admin_comment.go | 4 --- test/admin_auth_test.go | 58 ++++++++------------------------------- test/helpers_test.go | 6 ++-- 4 files changed, 14 insertions(+), 60 deletions(-) diff --git a/handlers/admin_auth.go b/handlers/admin_auth.go index db35969..0c88a2a 100644 --- a/handlers/admin_auth.go +++ b/handlers/admin_auth.go @@ -60,11 +60,7 @@ func (h *AdminHandler) AdminLogin (c *gin.Context) { return } - sendAccessMail := h.SendAccessMail - if sendAccessMail == nil { - sendAccessMail = services.SendProfileAccessMailToAdmins - } - if err := sendAccessMail(admin.ID, admin.Email); err != nil { + if err := services.SendProfileAccessMailToAdmins(admin.ID, admin.Email); err != nil { c.JSON(500, gin.H{"error": "failed to send the access mail"}) return } diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go index ba97977..daa5b1d 100644 --- a/handlers/admin_comment.go +++ b/handlers/admin_comment.go @@ -17,10 +17,6 @@ import ( type AdminHandler struct { DB *gorm.DB - - // SendAccessMail lets tests stub out the real mailer; when nil the - // handler falls back to services.SendProfileAccessMailToAdmins. - SendAccessMail func(adminID uint, email string) error } type CommentType struct { diff --git a/test/admin_auth_test.go b/test/admin_auth_test.go index 72cdaa9..ad04ffc 100644 --- a/test/admin_auth_test.go +++ b/test/admin_auth_test.go @@ -1,7 +1,6 @@ package test import ( - "errors" "fmt" "net/http" "testing" @@ -10,43 +9,21 @@ import ( ) // --- AdminLogin (sends the access mail) ------------------------------------- - -func TestAdminLogin_SendsAccessMail(t *testing.T) { - db := newTestDB(t) - admin := seedAdmin(t, db, "admin.login@iit.ac.in", models.TypeXENCivil) - - var gotID uint - var gotEmail string - e := newAdminAuthRouter(db, func(adminID uint, email string) error { - gotID = adminID - gotEmail = email - return nil - }) - - rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": admin.Email, - }) - - assertStatus(t, rec, 200) - if gotID != admin.ID || gotEmail != admin.Email { - t.Fatalf("expected access mail for (%d, %s), got (%d, %s)", admin.ID, admin.Email, gotID, gotEmail) - } - // the session must not start until the emailed link is clicked - if len(rec.Result().Cookies()) != 0 { - t.Fatalf("expected no cookie on login, got %v", rec.Result().Cookies()) - } -} +// +// The happy path dials SMTP inside services.SendProfileAccessMailToAdmins, so +// like the signup suites we only cover the branches that never reach the +// mailer. The link-consuming side of the flow is fully tested via AdminAccess. func TestAdminLogin_InvalidBody(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", []string{"bad"}) assertStatus(t, rec, 400) } func TestAdminLogin_NotFound(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ "email": "ghost.admin@iit.ac.in", }) @@ -60,33 +37,20 @@ func TestAdminLogin_Unverified(t *testing.T) { t.Fatalf("failed to unverify admin: %v", err) } - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ "email": admin.Email, }) assertStatus(t, rec, 401) } -func TestAdminLogin_MailFailure(t *testing.T) { - db := newTestDB(t) - admin := seedAdmin(t, db, "admin.mailfail@iit.ac.in", models.TypeJECivil) - - e := newAdminAuthRouter(db, func(uint, string) error { - return errors.New("smtp is down") - }) - rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": admin.Email, - }) - assertStatus(t, rec, 500) -} - // --- AdminAccess (completes the passwordless login) ------------------------- func TestAdminAccess_Success(t *testing.T) { db := newTestDB(t) admin := seedAdmin(t, db, "admin.access@iit.ac.in", models.TypeXENElectrical) - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) token := genToken(t, admin.ID, admin.Email, "admin") rec := doRequest(t, e, http.MethodGet, fmt.Sprintf("/api/auth/admin/access?token=%s", token), nil) @@ -102,21 +66,21 @@ func TestAdminAccess_Success(t *testing.T) { func TestAdminAccess_MissingToken(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) rec := doRequest(t, e, http.MethodGet, "/api/auth/admin/access", nil) assertStatus(t, rec, 401) } func TestAdminAccess_InvalidToken(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) rec := doRequest(t, e, http.MethodGet, "/api/auth/admin/access?token=not-a-jwt", nil) assertStatus(t, rec, 401) } func TestAdminAccess_AdminNotFound(t *testing.T) { db := newTestDB(t) - e := newAdminAuthRouter(db, nil) + e := newAdminAuthRouter(db) token := genToken(t, 999, "ghost.admin@iit.ac.in", "admin") rec := doRequest(t, e, http.MethodGet, fmt.Sprintf("/api/auth/admin/access?token=%s", token), nil) assertStatus(t, rec, 403) diff --git a/test/helpers_test.go b/test/helpers_test.go index 05e1b6e..7151976 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -210,11 +210,9 @@ func newAuthRouter(db *gorm.DB, auth gin.HandlerFunc) *gin.Engine { } // newAdminAuthRouter exposes the admin auth routes against the AdminHandler. -// sendAccessMail stubs out the real mailer so tests never dial SMTP; pass nil -// only when the mail path is unreachable in the scenario under test. -func newAdminAuthRouter(db *gorm.DB, sendAccessMail func(adminID uint, email string) error) *gin.Engine { +func newAdminAuthRouter(db *gorm.DB) *gin.Engine { e := gin.New() - h := &handlers.AdminHandler{DB: db, SendAccessMail: sendAccessMail} + h := &handlers.AdminHandler{DB: db} e.POST("/api/auth/admin/login", h.AdminLogin) e.GET("/api/auth/admin/access", h.AdminAccess) return e From 97938094921ded506608d04fb634a35f83818b7d Mon Sep 17 00:00:00 2001 From: ayush00git Date: Tue, 1 Sep 2026 20:09:42 +0530 Subject: [PATCH 3/4] feat: add a /admin/access frontend route --- app/src/pages/auth/AdminAccess.tsx | 102 +++++++++++++++-------------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/app/src/pages/auth/AdminAccess.tsx b/app/src/pages/auth/AdminAccess.tsx index 11bbfe8..a7694b7 100644 --- a/app/src/pages/auth/AdminAccess.tsx +++ b/app/src/pages/auth/AdminAccess.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { Link, useSearchParams, useNavigate } from 'react-router-dom'; import { MainLayout } from '../../components/layout/MainLayout'; import { Loader } from '../../components/Loader'; @@ -11,65 +11,52 @@ function dashboardFor(position: string): string { return '/'; } -type AccessStatus = 'loading' | 'error' | 'no-token'; +type AccessStatus = 'idle' | 'loading' | 'error' | 'no-token'; -// AdminAccess is the page the emailed login link lands on. It completes the -// passwordless login on mount: exchanges the link token for a session cookie -// and jumps straight to the admin's dashboard. +// AdminAccess is the page the emailed login link lands on. The explicit +// button keeps automated email link scanners from triggering the login; +// clicking it exchanges the link token for a session cookie, then redirects +// to the admin's dashboard. export function AdminAccess() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { refetch } = useAuth(); const token = searchParams.get('token'); - const [status, setStatus] = useState(token ? 'loading' : 'no-token'); + const [status, setStatus] = useState(token ? 'idle' : 'no-token'); const [message, setMessage] = useState(token ? '' : 'No login token found in the link.'); - const controllerRef = useRef(null); + const handleLogin = async () => { + if (!token) return; + setStatus('loading'); + setMessage(''); - useEffect(() => { - if (!token) { - return; - } - - // cancel any previous in-flight request (StrictMode double-invoke) - controllerRef.current?.abort(); - const controller = new AbortController(); - controllerRef.current = controller; + try { + const response = await fetch(`/api/auth/admin/access?token=${encodeURIComponent(token)}`, { + method: 'GET', + credentials: 'include', + }); - fetch(`/api/auth/admin/access?token=${encodeURIComponent(token)}`, { - method: 'GET', - credentials: 'include', - signal: controller.signal, - }) - .then(async (res) => { - const data = await res.json(); + const data = await response.json(); - if (res.ok) { - const dest = dashboardFor(data.position ?? ''); - if (dest === '/') { - setStatus('error'); - setMessage(`Unknown position "${data.position}" — contact admin.`); - return; - } + if (response.ok) { + const dest = dashboardFor(data.position ?? ''); + if (dest === '/') { + setStatus('error'); + setMessage(`Unknown position "${data.position}" — contact admin.`); + } else { refetch(); navigate(dest, { replace: true }); - } else { - setStatus('error'); - setMessage(data.error || 'Login failed. The link may be expired or invalid.'); } - }) - .catch((err) => { - if ((err as Error).name === 'AbortError') return; // cancelled — ignore + } else { setStatus('error'); - setMessage('Failed to connect to the server. Please try again.'); - }); - - return () => { - controller.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // run once on mount — token never changes after the page loads + setMessage(data.error || 'Login failed. The link may be expired or invalid.'); + } + } catch { + setStatus('error'); + setMessage('Failed to connect to the server. Please try again.'); + } + }; return ( @@ -81,11 +68,28 @@ export function AdminAccess() { }} > - {/* Loading */} - {status === 'loading' && ( -
- -

Logging you in, please wait…

+ {/* Idle / Loading — ready to complete login */} + {(status === 'idle' || status === 'loading') && ( +
+
+ + + +
+
+

Staff Portal Login

+

+ Continue to log in to your staff account. +

+
+
)} From df2436100b74c09a5513d02c36e6096e371cb263 Mon Sep 17 00:00:00 2001 From: ayush00git Date: Tue, 1 Sep 2026 20:16:35 +0530 Subject: [PATCH 4/4] fix: keep admin dashboard specific --- app/src/components/layout/Navbar.tsx | 158 +++++++++++++++------------ app/src/constants/adminDashboard.ts | 8 ++ app/src/context/auth-context.ts | 2 + app/src/pages/auth/AdminAccess.tsx | 10 +- handlers/auth.go | 15 +++ test/auth_test.go | 23 +++- 6 files changed, 136 insertions(+), 80 deletions(-) create mode 100644 app/src/constants/adminDashboard.ts diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index 298c470..df0c310 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -2,15 +2,21 @@ import { useState } from 'react'; import { ChevronDown, Menu, X, User, LogOut } from 'lucide-react'; import { Link } from 'react-router-dom'; import { useAuth } from '../../context/auth-context'; +import { adminDashboardFor } from '../../constants/adminDashboard'; export function Navbar() { const [mobileOpen, setMobileOpen] = useState(false); const [lodgeOpen, setLodgeOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false); const [loginDropdownOpen, setLoginDropdownOpen] = useState(false); - const { status } = useAuth(); + const { status, profile } = useAuth(); const isAuth = status === 'loading' ? null : status === 'authenticated'; + // admins have no /profile page — their "Profile" entry points at the dashboard + const isAdmin = Boolean(profile?.position); + const profileHref = isAdmin ? adminDashboardFor(profile!.position!) : '/profile'; + const profileLabel = isAdmin ? 'Dashboard' : 'Profile'; + const closeMobile = () => { setMobileOpen(false); setLodgeOpen(false); @@ -36,37 +42,42 @@ export function Navbar() { -
  • - -
    - Employee - Warden - Centre Head -
    -
  • + {/* complaint-side items are for users, not admin staff */} + {!isAdmin && ( + <> +
  • + +
    + Employee + Warden + Centre Head +
    +
  • -
  • - -
  • +
  • + +
  • -
  • - -
    - Staff Login -
    -
  • +
  • + +
    + Staff Login +
    +
  • -
  • - -
  • +
  • + +
  • + + )}
  • @@ -86,9 +97,9 @@ export function Navbar() { Logout - + - Profile + {profileLabel} ) : ( @@ -123,9 +134,9 @@ export function Navbar() { Logout - + - Profile + {profileLabel} ) : ( @@ -162,9 +173,9 @@ export function Navbar() { Logout - + - Profile + {profileLabel}
  • ) : ( @@ -190,47 +201,52 @@ export function Navbar() { )} - {/* Lodge Complaint accordion */} -
    - - {lodgeOpen && ( -
    - Employee - Warden - Centre Head + {/* complaint-side items are for users, not admin staff */} + {!isAdmin && ( + <> + {/* Lodge Complaint accordion */} +
    + + {lodgeOpen && ( +
    + Employee + Warden + Centre Head +
    + )}
    - )} -
    - + - {/* Administration accordion */} -
    - - {adminOpen && ( -
    - Staff Login + {/* Administration accordion */} +
    + + {adminOpen && ( +
    + Staff Login +
    + )}
    - )} -
    - + + + )}
    Contact Us diff --git a/app/src/constants/adminDashboard.ts b/app/src/constants/adminDashboard.ts new file mode 100644 index 0000000..3fce0cb --- /dev/null +++ b/app/src/constants/adminDashboard.ts @@ -0,0 +1,8 @@ +// adminDashboardFor maps an admin position (XEN_* / AE_* / JE_*) to its +// dashboard route; '/' means the position is unknown. +export function adminDashboardFor(position: string): string { + if (position.startsWith('XEN')) return '/admin/xen'; + if (position.startsWith('AE')) return '/admin/ae'; + if (position.startsWith('JE')) return '/admin/je'; + return '/'; +} diff --git a/app/src/context/auth-context.ts b/app/src/context/auth-context.ts index 48ecb63..3971631 100644 --- a/app/src/context/auth-context.ts +++ b/app/src/context/auth-context.ts @@ -11,6 +11,8 @@ export interface ProfileData { type?: string; hostel?: string; building?: string; + /** Only present for admin (XEN / AE / JE) sessions. */ + position?: string; } export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'rate-limited' | 'error'; diff --git a/app/src/pages/auth/AdminAccess.tsx b/app/src/pages/auth/AdminAccess.tsx index a7694b7..ca54534 100644 --- a/app/src/pages/auth/AdminAccess.tsx +++ b/app/src/pages/auth/AdminAccess.tsx @@ -3,13 +3,7 @@ import { Link, useSearchParams, useNavigate } from 'react-router-dom'; import { MainLayout } from '../../components/layout/MainLayout'; import { Loader } from '../../components/Loader'; import { useAuth } from '../../context/auth-context'; - -function dashboardFor(position: string): string { - if (position.startsWith('XEN')) return '/admin/xen'; - if (position.startsWith('AE')) return '/admin/ae'; - if (position.startsWith('JE')) return '/admin/je'; - return '/'; -} +import { adminDashboardFor } from '../../constants/adminDashboard'; type AccessStatus = 'idle' | 'loading' | 'error' | 'no-token'; @@ -40,7 +34,7 @@ export function AdminAccess() { const data = await response.json(); if (response.ok) { - const dest = dashboardFor(data.position ?? ''); + const dest = adminDashboardFor(data.position ?? ''); if (dest === '/') { setStatus('error'); setMessage(`Unknown position "${data.position}" — contact admin.`); diff --git a/handlers/auth.go b/handlers/auth.go index 5035556..514de8f 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -88,6 +88,21 @@ func (h *AuthHandler) UserProfile (c *gin.Context) { return } userProfile = profile + case "admin": + var profile models.Admin + result := h.DB.Where("email = ?", email).Take(&profile) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed to fetch user profile"}) + return + } + // hand-pick the fields so the password hash never leaves the server + userProfile = gin.H{ + "id": profile.ID, + "email": profile.Email, + "position": profile.Position, + "is_verified": profile.IsVerified, + "created_at": profile.CreatedAt, + } default: c.JSON(404, gin.H{"error": "undefined role"}) return diff --git a/test/auth_test.go b/test/auth_test.go index 5c4962d..2bca534 100644 --- a/test/auth_test.go +++ b/test/auth_test.go @@ -134,7 +134,28 @@ func TestUserProfile_Unauthenticated(t *testing.T) { func TestUserProfile_UndefinedRole(t *testing.T) { db := newTestDB(t) // authenticated, but with a role the handler does not recognise - e := newAuthRouter(db, authAsRole(1, "someone@iit.ac.in", "admin")) + e := newAuthRouter(db, authAsRole(1, "someone@iit.ac.in", "alien")) rec := doRequest(t, e, http.MethodGet, "/api/profile", nil) assertStatus(t, rec, 404) } + +func TestUserProfile_Admin(t *testing.T) { + db := newTestDB(t) + admin := seedAdmin(t, db, "admin.profile@iit.ac.in", models.TypeJEElectrical) + + e := newAuthRouter(db, authAsRole(admin.ID, admin.Email, "admin")) + rec := doRequest(t, e, http.MethodGet, "/api/profile", nil) + + assertStatus(t, rec, 200) + out := decodeBody(t, rec) + if out["position"] != string(models.TypeJEElectrical) { + t.Fatalf("expected position %s, got %v", models.TypeJEElectrical, out) + } + if out["email"] != admin.Email { + t.Fatalf("expected email %s, got %v", admin.Email, out) + } + // the password hash must never be exposed + if _, leaked := out["password"]; leaked { + t.Fatalf("admin profile response leaked the password field: %v", out) + } +}