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 (
+
+ );
+}
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
+
+
+
+ {members.length > 0 && (
+
+
+
+ | Name |
+ Email |
+
+
+
+ {members.map((member, index) => (
+
+ | {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