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/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 new file mode 100644 index 0000000..ca54534 --- /dev/null +++ b/app/src/pages/auth/AdminAccess.tsx @@ -0,0 +1,114 @@ +import { 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'; +import { adminDashboardFor } from '../../constants/adminDashboard'; + +type AccessStatus = 'idle' | 'loading' | 'error' | 'no-token'; + +// 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 ? 'idle' : 'no-token'); + const [message, setMessage] = useState(token ? '' : 'No login token found in the link.'); + + const handleLogin = async () => { + if (!token) return; + setStatus('loading'); + setMessage(''); + + try { + const response = await fetch(`/api/auth/admin/access?token=${encodeURIComponent(token)}`, { + method: 'GET', + credentials: 'include', + }); + + const data = await response.json(); + + if (response.ok) { + const dest = adminDashboardFor(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 { + setStatus('error'); + setMessage('Failed to connect to the server. Please try again.'); + } + }; + + return ( + +
    + + {/* Idle / Loading — ready to complete login */} + {(status === 'idle' || status === 'loading') && ( +
    +
    + + + +
    +
    +

    Staff Portal Login

    +

    + Continue to log in to your staff account. +

    +
    + +
    + )} + + {/* 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..0c88a2a 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,32 @@ func (h *AdminHandler) AdminLogin (c *gin.Context) { return } - err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(inputs.Password)) + if err := services.SendProfileAccessMailToAdmins(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 +98,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/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/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..ad04ffc 100644 --- a/test/admin_auth_test.go +++ b/test/admin_auth_test.go @@ -1,59 +1,87 @@ package test import ( + "fmt" "net/http" "testing" "github.com/ayush00git/cms-web/models" ) -// --- AdminLogin ------------------------------------------------------------- +// --- AdminLogin (sends the access mail) ------------------------------------- +// +// 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_Success(t *testing.T) { +func TestAdminLogin_InvalidBody(t *testing.T) { db := newTestDB(t) - admin := seedAdmin(t, db, "admin.login@iit.ac.in", models.TypeXENCivil) + 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) rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": admin.Email, - "password": testPassword, + "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) + rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ + "email": admin.Email, + }) + assertStatus(t, rec, 401) +} + +// --- 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) + 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.TypeXENCivil) { - t.Fatalf("expected position %s in response, got %v", models.TypeXENCivil, out) + 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 TestAdminLogin_InvalidBody(t *testing.T) { +func TestAdminAccess_MissingToken(t *testing.T) { db := newTestDB(t) e := newAdminAuthRouter(db) - rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", []string{"bad"}) - assertStatus(t, rec, 400) + rec := doRequest(t, e, http.MethodGet, "/api/auth/admin/access", nil) + assertStatus(t, rec, 401) } -func TestAdminLogin_NotFound(t *testing.T) { +func TestAdminAccess_InvalidToken(t *testing.T) { db := newTestDB(t) e := newAdminAuthRouter(db) - rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": "ghost.admin@iit.ac.in", - "password": testPassword, - }) - assertStatus(t, rec, 404) + rec := doRequest(t, e, http.MethodGet, "/api/auth/admin/access?token=not-a-jwt", nil) + assertStatus(t, rec, 401) } -func TestAdminLogin_WrongPassword(t *testing.T) { +func TestAdminAccess_AdminNotFound(t *testing.T) { db := newTestDB(t) - admin := seedAdmin(t, db, "admin.wrongpw@iit.ac.in", models.TypeAECivil) - e := newAdminAuthRouter(db) - rec := doRequest(t, e, http.MethodPost, "/api/auth/admin/login", map[string]any{ - "email": admin.Email, - "password": "nope", - }) - assertStatus(t, rec, 401) + 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/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) + } +} diff --git a/test/helpers_test.go b/test/helpers_test.go index b41150c..7151976 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -209,11 +209,12 @@ func newAuthRouter(db *gorm.DB, auth gin.HandlerFunc) *gin.Engine { return e } -// newAdminAuthRouter exposes the admin login route against the AdminHandler. +// newAdminAuthRouter exposes the admin auth routes against the AdminHandler. func newAdminAuthRouter(db *gorm.DB) *gin.Engine { e := gin.New() h := &handlers.AdminHandler{DB: db} e.POST("/api/auth/admin/login", h.AdminLogin) + e.GET("/api/auth/admin/access", h.AdminAccess) return e }