diff --git a/client/src/pages/AdminLogin.js b/client/src/pages/AdminLogin.js new file mode 100644 index 0000000..09c47c5 --- /dev/null +++ b/client/src/pages/AdminLogin.js @@ -0,0 +1,137 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router"; + +export default function AdminLogin() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setIsLoading(true); + + if (!username || !password) { + setError("⚠️ Please enter both username and password."); + setIsLoading(false); + return; + } + + try { + const resp = await fetch("/api/admin/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username, + password, + }), + }); + + const data = await resp.json(); + + if (resp.ok && data.success) { + // Store the auth token in localStorage + localStorage.setItem("adminToken", data.token); + setError(""); + navigate("/admin-members"); + } else { + setError(`❌ ${data.error || "Login failed"}`); + } + } catch (err) { + setError(`❌ Network error: ${err.message}`); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Admin Login

+
+
+ + setUsername(e.target.value)} + style={{ + width: "100%", + padding: "0.5rem", + marginTop: "0.5rem", + border: "1px solid #ccc", + borderRadius: "4px", + boxSizing: "border-box", + }} + placeholder="Enter your username" + /> +
+ +
+ + setPassword(e.target.value)} + style={{ + width: "100%", + padding: "0.5rem", + marginTop: "0.5rem", + border: "1px solid #ccc", + borderRadius: "4px", + boxSizing: "border-box", + }} + placeholder="Enter your password" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} diff --git a/client/src/pages/AdminMembersView.js b/client/src/pages/AdminMembersView.js new file mode 100644 index 0000000..c652bae --- /dev/null +++ b/client/src/pages/AdminMembersView.js @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { useNavigate } from 'react-router'; +import Table from '@mui/material/Table'; + +export default function AdminMembersView() { + const [year, setYear] = useState(""); + const [members, setMembers] = useState([]); + const [status, setStatus] = useState(""); + const navigate = useNavigate(); + + const isAdmin = () => { + return localStorage.getItem('adminToken') !== null; + }; + + if (!isAdmin()) { + navigate('/admin-login'); + return
Redirecting...
; + } + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + const resp = await fetch(`/api/admin/members/view?year=${year}`); + const data = await resp.json(); + + if (data.success) { + setMembers(data.members); + } else { + setStatus(`❌ Server error: ${data.error}`); + } + } catch (err) { + setStatus(`❌ Network error: ${err.message}`); + } + }; + + return ( +
+

View Members

+ +
+
+ + + setYear(e.target.value)} + required + min="1900" + max="2100" + style={{ width: "150px", padding: "0.3rem" }} + /> +
+ + +
+ + {members.length > 0 && ( + + + + + + + + + {members.map((member, index) => ( + + + + + ))} + +
NameEmail
{member.Name}{member.Email}
+ )} + + {status &&

{status}

} +
+ ); +} diff --git a/server/services/auth.go b/server/services/auth.go new file mode 100644 index 0000000..12a3860 --- /dev/null +++ b/server/services/auth.go @@ -0,0 +1,127 @@ +package services + +import ( + "crypto/sha256" + "encoding/hex" + "log" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" +) + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + Error string `json:"error,omitempty"` +} + +var jwtSecret string + +func init() { + jwtSecret = os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "your-secret-key-change-in-production" + log.Println("WARNING: JWT_SECRET not set, using default value. Change this in production!") + } +} + +// GetAdminCredentials retrieves admin credentials from environment variables +// Format: ADMIN_USERS=username:password|username2:password2 +func getAdminCredentials() map[string]string { + adminUsers := os.Getenv("ADMIN_USERS") + if adminUsers == "" { + // Default admin user (change in production) + adminUsers = "admin:admin" + log.Println("WARNING: ADMIN_USERS not set, using default admin:admin") + } + + credentials := make(map[string]string) + for _, userPass := range strings.Split(adminUsers, "|") { + parts := strings.Split(strings.TrimSpace(userPass), ":") + if len(parts) == 2 { + credentials[parts[0]] = parts[1] + } + } + return credentials +} + +// hashPassword hashes a password using SHA256 +func hashPassword(password string) string { + hash := sha256.Sum256([]byte(password)) + return hex.EncodeToString(hash[:]) +} + +// ValidateAdminLogin checks if the provided username and password are valid +func ValidateAdminLogin(username, password string) (bool, error) { + if username == "" || password == "" { + return false, errors.New("username and password are required") + } + + credentials := getAdminCredentials() + storedPassword, exists := credentials[username] + + if !exists { + // Return false but not an error for security reasons (don't reveal if user exists) + return false, nil + } + + // Compare passwords (you could enhance this with bcrypt in production) + if storedPassword != password { + return false, nil + } + + return true, nil +} + +// GenerateAuthToken generates a JWT token for an authenticated admin user +func GenerateAuthToken(username string) (string, error) { + claims := jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(jwtSecret)) + + if err != nil { + return "", errors.Wrap(err, "failed to generate token") + } + + return tokenString, nil +} + +// VerifyAuthToken verifies a JWT token and returns the username if valid +func VerifyAuthToken(tokenString string) (string, error) { + token, err := jwt.ParseWithClaims(tokenString, &jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + + if err != nil { + return "", errors.Wrap(err, "failed to parse token") + } + + if !token.Valid { + return "", errors.New("invalid token") + } + + claims, ok := token.Claims.(*jwt.MapClaims) + if !ok { + return "", errors.New("invalid token claims") + } + + username, ok := (*claims)["username"].(string) + if !ok { + return "", errors.New("username not found in token") + } + + return username, nil +} diff --git a/server/services/members.go b/server/services/members.go new file mode 100644 index 0000000..5b9abcc --- /dev/null +++ b/server/services/members.go @@ -0,0 +1,157 @@ +package services + +import ( + "encoding/csv" + "fmt" + "log" + "strings" + + "github.com/pkg/errors" + + "go-sjles-pta-vote/server/db" +) + +type Member struct { + Name string + Email string +} + +const BATCH_SIZE = 100 + +func ParseMembersFromBytes(year int, fileBytes []byte) error { + reader := csv.NewReader(strings.NewReader(string(fileBytes))) + reader.FieldsPerRecord = -1 // Allow variable number of fields per record + records, err := reader.ReadAll() + if err != nil { + return errors.Wrap(err, "failed to read CSV from bytes") + } + + var members []Member + + for i, record := range records { + if i == 0 { + continue // Skip the first line (column headers) + } + if len(record) < 4 { + continue + } + + firstName := strings.TrimSpace(record[1]) + lastName := strings.TrimSpace(record[2]) + email := strings.TrimSpace(record[3]) + + members = append(members, Member{ + Name: fmt.Sprintf("%s %s", firstName, lastName), + Email: email, + }) + + if len(record) < 30 { + continue + } + + email2 := strings.TrimSpace(record[27]) + if email2 != "" { + firstName2 := strings.TrimSpace(record[29]) + lastName2 := strings.TrimSpace(record[28]) + + members = append(members, Member{ + Name: fmt.Sprintf("%s %s", firstName2, lastName2), + Email: email2, + }) + } + } + + return saveMember(year, members) +} + +func saveMember(year int, members []Member) error { + insertMembersQuery := ` + INSERT OR REPLACE INTO members (email, member_name, school_year) + VALUES ($1, $2, $3) + ` + log.Printf("Starting to save %d members for year %d", len(members), year) + + db_conn, err := db.Connect() + if err != nil { + return errors.Wrap(err, "failed to connect to database") + } + defer db_conn.Close() + + tx, err := db_conn.Begin() + if err != nil { + return errors.Wrap(err, "failed to begin transaction") + } + + stmt, err := tx.Prepare(insertMembersQuery) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to prepare statement") + } + defer stmt.Close() + + for index, member := range members { + _, err = stmt.Exec(member.Email, member.Name, year) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to execute insert") + } + + if (index+1) % BATCH_SIZE == 0 { + err = tx.Commit() + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to commit transaction") + } + + tx, err = db_conn.Begin() + if err != nil { + return errors.Wrap(err, "failed to begin new transaction") + } + + stmt, err = tx.Prepare(insertMembersQuery) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to prepare new statement") + } + } + } + + return tx.Commit() +} + +func GetMembersByYear(year int) ([]Member, error) { + query := ` + SELECT member_name, email + FROM members + WHERE school_year = $1 + ORDER BY member_name ASC + ` + + db_conn, err := db.Connect() + if err != nil { + return nil, errors.Wrap(err, "failed to connect to database") + } + defer db_conn.Close() + + rows, err := db_conn.Query(query, year) + if err != nil { + return nil, errors.Wrap(err, "failed to execute query") + } + defer rows.Close() + + var members []Member + + for rows.Next() { + var member Member + if err := rows.Scan(&member.Name, &member.Email); err != nil { + return nil, errors.Wrap(err, "failed to scan row") + } + members = append(members, member) + } + + if err := rows.Err(); err != nil { + return nil, errors.Wrap(err, "row iteration error") + } + + return members, nil +} \ No newline at end of file