Adding page to view currently uploaded members, as well as verifying admin is logged in

This commit is contained in:
2026-01-22 15:31:36 -05:00
parent 81ccc3035b
commit 0f6e8a8350
4 changed files with 506 additions and 0 deletions

View File

@@ -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 (
<div
style={{
padding: "2rem",
maxWidth: "400px",
margin: "5rem auto",
border: "1px solid #ddd",
borderRadius: "8px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
>
<h2 style={{ textAlign: "center", marginBottom: "2rem" }}>Admin Login</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<div
style={{
padding: "1rem",
marginBottom: "1rem",
backgroundColor: "#ffe0e0",
border: "1px solid #ff6b6b",
borderRadius: "4px",
color: "#c92a2a",
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
style={{
width: "100%",
padding: "0.75rem",
backgroundColor: isLoading ? "#ccc" : "#007bff",
color: "white",
border: "none",
borderRadius: "4px",
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: "1rem",
fontWeight: "bold",
}}
>
{isLoading ? "Logging in..." : "Login"}
</button>
</form>
</div>
);
}

View File

@@ -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 <div>Redirecting...</div>;
}
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 (
<div style={{ padding: "2rem" }}>
<h2>View Members</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="year">Year:</label>
<input
type="number"
id="year"
name="year"
value={year}
onChange={(e) => setYear(e.target.value)}
required
min="1900"
max="2100"
style={{ width: "150px", padding: "0.3rem" }}
/>
</div>
<button type="submit" style={{ padding: "0.5rem 1rem" }}>
View Members
</button>
</form>
{members.length > 0 && (
<Table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{members.map((member, index) => (
<tr key={index}>
<td>{member.Name}</td>
<td>{member.Email}</td>
</tr>
))}
</tbody>
</Table>
)}
{status && <p style={{ marginTop: "1rem" }}>{status}</p>}
</div>
);
}

127
server/services/auth.go Normal file
View File

@@ -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
}

157
server/services/members.go Normal file
View File

@@ -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
}