-
- {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() {
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
}