Fixed auth security

This commit is contained in:
2025-02-25 09:59:25 -06:00
parent 0be45ac39b
commit 4d2353b2c3
24 changed files with 681 additions and 350 deletions

View File

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { fetchWithAuth } from "../utils/auth-client";
import "../styles/global.css";
interface FormData {
@ -37,7 +38,7 @@ const RSVPForm = () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch("/api/rsvp", {
const response = await fetchWithAuth("/api/rsvp", {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -1,25 +1,30 @@
import { useState, useEffect } from "react";
import type { RSVPItem } from "../lib/types";
import { fetchWithAuth, getAuthToken } from "../utils/auth-client";
const RSVPManager = () => {
const [rsvpList, setRSVPList] = useState<RSVPItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchRSVPList();
}, []);
const fetchRSVPList = async () => {
// Don't try to fetch if we don't have a token yet
if (!getAuthToken()) {
setError("No authentication token found");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch("/api/rsvp");
const response = await fetchWithAuth("/api/rsvp");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRSVPList(data);
setError(null);
} catch (e: any) {
setError(e.message);
console.error("Failed to fetch RSVP list:", e);
@ -28,11 +33,25 @@ const RSVPManager = () => {
}
};
useEffect(() => {
fetchRSVPList();
// Add listener for auth success to retry fetching
const handleAuthSuccess = () => {
fetchRSVPList();
};
document.addEventListener("auth-success", handleAuthSuccess);
return () => document.removeEventListener("auth-success", handleAuthSuccess);
}, []);
if (loading) {
return <div className="text-center">Loading RSVP list...</div>;
}
if (error) {
if (error === "No authentication token found") {
return <div className="text-center">Initializing...</div>;
}
return <div className="text-red-500 text-center">Error: {error}</div>;
}

View File

@ -1,36 +1,31 @@
import { useState, useEffect } from "react";
import type { RegistryItem } from "../lib/types";
import { fetchWithAuth } from "../utils/auth-client";
const RegistryList = () => {
const [registryItems, setRegistryItems] = useState<RegistryItem[]>([]);
const [claimedItems, setClaimedItems] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [claimantName, setClaimantName] = useState("");
const [showClaimants, setShowClaimants] = useState<Set<string>>(new Set());
const [claimedItems, setClaimedItems] = useState<Set<string>>(new Set());
useEffect(() => {
fetchRegistryItems();
}, []);
useEffect(() => {
// Dispatch event if registry is empty
if (!loading && registryItems.length === 0) {
const event = new CustomEvent("registry-empty");
document.dispatchEvent(event);
}
}, [loading, registryItems]);
const fetchRegistryItems = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/registry");
const response = await fetchWithAuth("/api/registry");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRegistryItems(data);
if (data.length === 0) {
document.dispatchEvent(new Event("registry-empty"));
}
} catch (e: any) {
setError(e.message);
console.error("Failed to fetch registry items:", e);
@ -39,7 +34,7 @@ const RegistryList = () => {
}
};
const handleCheckboxChange = (itemId: string) => {
const handleItemClick = (itemId: string) => {
const newClaimedItems = new Set(claimedItems);
if (newClaimedItems.has(itemId)) {
newClaimedItems.delete(itemId);
@ -49,16 +44,6 @@ const RegistryList = () => {
setClaimedItems(newClaimedItems);
};
const toggleClaimantVisibility = (itemId: string) => {
const newShowClaimants = new Set(showClaimants);
if (newShowClaimants.has(itemId)) {
newShowClaimants.delete(itemId);
} else {
newShowClaimants.add(itemId);
}
setShowClaimants(newShowClaimants);
};
const handleSubmit = async () => {
if (!claimantName.trim()) {
setError("Please enter your name before claiming items");
@ -79,7 +64,7 @@ const RegistryList = () => {
try {
for (const item of updates) {
const response = await fetch(`/api/registry/${item.id}`, {
const response = await fetchWithAuth(`/api/registry/${item.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
@ -109,102 +94,84 @@ const RegistryList = () => {
return <div className="text-red-500 text-center">Error: {error}</div>;
}
const availableItems = registryItems.filter((item) => !item.taken);
const claimedItemsArray = registryItems.filter((item) => item.taken);
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Wedding Registry</h1>
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr>
<th>Item</th>
<th>Link</th>
<th>Status</th>
<th>Claim</th>
</tr>
</thead>
<tbody>
{registryItems.map((item) => (
<tr key={item.id}>
<td>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{item.name}
{item.taken && (
<div className="flex items-center gap-2">
<span className="badge badge-success">Taken</span>
<button
onClick={() => toggleClaimantVisibility(item.id)}
className="btn btn-xs btn-ghost"
>
{showClaimants.has(item.id) ? "Hide" : "Show"}
</button>
</div>
)}
</div>
{item.taken &&
item.claimedBy &&
showClaimants.has(item.id) && (
<div className="text-sm text-gray-500 italic">
Claimed by: {item.claimedBy}
</div>
)}
</div>
</td>
<td>
{availableItems.length > 0 && (
<>
<div className="mb-4">
<input
type="text"
value={claimantName}
onChange={(e) => setClaimantName(e.target.value)}
placeholder="Enter your name"
className="input input-bordered w-full max-w-xs"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{availableItems.map((item) => (
<div
key={item.id}
className={`card bg-base-100 shadow-xl cursor-pointer ${
claimedItems.has(item.id)
? "border-4 border-primary"
: ""
}`}
onClick={() => handleItemClick(item.id)}
>
<div className="card-body">
<h2 className="card-title">{item.name}</h2>
{item.link && (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="link link-primary"
onClick={(e) => e.stopPropagation()}
>
View
View Item
</a>
)}
</td>
<td>{item.taken ? "Claimed" : "Available"}</td>
<td>
{!item.taken ? (
<input
type="checkbox"
className="checkbox"
checked={claimedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
/>
) : <input
type="checkbox"
className="checkbox"
disabled
checked={true}
/>}
</td>
</tr>
</div>
</div>
))}
</div>
{claimedItems.size > 0 && (
<div className="text-center mb-8">
<button
onClick={handleSubmit}
className="btn btn-primary"
disabled={!claimantName.trim()}
>
Claim Selected Items
</button>
</div>
)}
</>
)}
{claimedItemsArray.length > 0 && (
<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">Already Claimed</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{claimedItemsArray.map((item) => (
<div
key={item.id}
className="card bg-base-100 shadow-xl opacity-50"
>
<div className="card-body">
<h2 className="card-title">{item.name}</h2>
<p className="text-sm">
Claimed by: {item.claimedBy}
</p>
</div>
</div>
))}
</tbody>
</table>
</div>
{claimedItems.size > 0 && (
<div className="mt-4 flex flex-col gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Your Name</span>
</label>
<input
type="text"
className="input input-bordered"
value={claimantName}
onChange={(e) => setClaimantName(e.target.value)}
placeholder="Enter your name"
required
/>
</div>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={!claimantName.trim()}
>
Submit Claims
</button>
</div>
)}
</div>

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import type { RegistryItem } from "../lib/types";
import { fetchWithAuth, getAuthToken } from "../utils/auth-client";
const RegistryManager = () => {
const [registryItems, setRegistryItems] = useState<RegistryItem[]>([]);
@ -8,20 +9,24 @@ const RegistryManager = () => {
const [newItem, setNewItem] = useState({ name: "", link: "" });
const [showClaimants, setShowClaimants] = useState<Set<string>>(new Set());
useEffect(() => {
fetchRegistryItems();
}, []);
const fetchRegistryItems = async () => {
// Don't try to fetch if we don't have a token yet
if (!getAuthToken()) {
setError("No authentication token found");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch("/api/registry");
const response = await fetchWithAuth("/api/registry");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRegistryItems(data);
setError(null);
} catch (e: any) {
setError(e.message);
console.error("Failed to fetch registry items:", e);
@ -30,12 +35,23 @@ const RegistryManager = () => {
}
};
useEffect(() => {
fetchRegistryItems();
// Add listener for auth success to retry fetching
const handleAuthSuccess = () => {
fetchRegistryItems();
};
document.addEventListener("auth-success", handleAuthSuccess);
return () => document.removeEventListener("auth-success", handleAuthSuccess);
}, []);
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!newItem.name.trim()) return;
try {
const response = await fetch("/api/registry", {
const response = await fetchWithAuth("/api/registry", {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -55,11 +71,9 @@ const RegistryManager = () => {
}
};
const handleDeleteItem = async (itemId: string) => {
if (!confirm("Are you sure you want to delete this item?")) return;
const handleDeleteItem = async (id: string) => {
try {
const response = await fetch(`/api/registry/${itemId}`, {
const response = await fetchWithAuth(`/api/registry/${id}`, {
method: "DELETE",
});
@ -74,12 +88,12 @@ const RegistryManager = () => {
}
};
const toggleClaimantVisibility = (itemId: string) => {
const toggleShowClaimant = (id: string) => {
const newShowClaimants = new Set(showClaimants);
if (newShowClaimants.has(itemId)) {
newShowClaimants.delete(itemId);
if (newShowClaimants.has(id)) {
newShowClaimants.delete(id);
} else {
newShowClaimants.add(itemId);
newShowClaimants.add(id);
}
setShowClaimants(newShowClaimants);
};
@ -89,50 +103,34 @@ const RegistryManager = () => {
}
if (error) {
if (error === "No authentication token found") {
return <div className="text-center">Initializing...</div>;
}
return <div className="text-red-500 text-center">Error: {error}</div>;
}
return (
<div className="container mx-auto p-4">
<form onSubmit={handleAddItem} className="mb-8">
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">Add New Item</h2>
<div className="form-control">
<label className="label">
<span className="label-text">Item Name</span>
</label>
<input
type="text"
placeholder="Enter item name"
className="input input-bordered"
value={newItem.name}
onChange={(e) =>
setNewItem({ ...newItem, name: e.target.value })
}
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Item Link (optional)</span>
</label>
<input
type="url"
placeholder="Enter item link"
className="input input-bordered"
value={newItem.link}
onChange={(e) =>
setNewItem({ ...newItem, link: e.target.value })
}
/>
</div>
<div className="card-actions justify-end">
<button type="submit" className="btn btn-primary">
Add Item
</button>
</div>
</div>
<div className="flex flex-col gap-4">
<input
type="text"
value={newItem.name}
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
placeholder="Item name"
className="input input-bordered"
required
/>
<input
type="url"
value={newItem.link}
onChange={(e) => setNewItem({ ...newItem, link: e.target.value })}
placeholder="Item link (optional)"
className="input input-bordered"
/>
<button type="submit" className="btn btn-primary">
Add Item
</button>
</div>
</form>
@ -140,7 +138,7 @@ const RegistryManager = () => {
<table className="table w-full">
<thead>
<tr>
<th>Item</th>
<th>Name</th>
<th>Link</th>
<th>Status</th>
<th>Actions</th>
@ -149,35 +147,7 @@ const RegistryManager = () => {
<tbody>
{registryItems.map((item) => (
<tr key={item.id}>
<td>
<div className="flex items-center gap-2">
{item.name}
{item.taken && (
<>
<span className="badge badge-success">Taken</span>
{item.claimedBy && (
<button
onClick={() => toggleClaimantVisibility(item.id)}
className="btn btn-ghost btn-xs"
>
{showClaimants.has(item.id) ? (
<i className="fas fa-eye-slash" />
) : (
<i className="fas fa-eye" />
)}
</button>
)}
</>
)}
</div>
{item.taken &&
item.claimedBy &&
showClaimants.has(item.id) && (
<div className="text-sm text-gray-500">
Claimed by: {item.claimedBy}
</div>
)}
</td>
<td>{item.name}</td>
<td>
{item.link && (
<a
@ -186,15 +156,38 @@ const RegistryManager = () => {
rel="noopener noreferrer"
className="link link-primary"
>
View
View Item
</a>
)}
</td>
<td>{item.taken ? "Claimed" : "Available"}</td>
<td>
<div className="flex items-center gap-2">
<span
className={`badge ${
item.taken ? "badge-error" : "badge-success"
}`}
>
{item.taken ? "Claimed" : "Available"}
</span>
{item.taken && (
<button
onClick={() => toggleShowClaimant(item.id)}
className="btn btn-xs"
>
{showClaimants.has(item.id) ? "Hide" : "Show"} Claimant
</button>
)}
</div>
{item.taken && showClaimants.has(item.id) && (
<div className="text-sm mt-1">
Claimed by: {item.claimedBy}
</div>
)}
</td>
<td>
<button
className="btn btn-error btn-sm"
onClick={() => handleDeleteItem(item.id)}
className="btn btn-error btn-sm"
>
Delete
</button>

View File

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { setAuthData } from "../utils/auth-client";
interface SignInProps {
onSuccess?: () => void;
@ -8,7 +9,7 @@ interface SignInProps {
interface ApiResponse {
success: boolean;
error?: string;
role?: string;
token?: string;
}
const SignIn = ({ onSuccess, requiredRole = "guest" }: SignInProps) => {
@ -16,16 +17,17 @@ const SignIn = ({ onSuccess, requiredRole = "guest" }: SignInProps) => {
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize disabled state to true since code is empty initially
const isDisabled = !code || isSubmitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (isDisabled) return;
setIsSubmitting(true);
setError(null);
try {
// Clear existing authentication before attempting new sign-in
sessionStorage.removeItem("isAuthenticated");
sessionStorage.removeItem("role");
const response = await fetch("/api/auth", {
method: "POST",
headers: {
@ -36,21 +38,11 @@ const SignIn = ({ onSuccess, requiredRole = "guest" }: SignInProps) => {
const data: ApiResponse = await response.json();
if (!response.ok || !data.success) {
if (!response.ok || !data.success || !data.token) {
throw new Error(data.error || "Invalid code");
}
sessionStorage.setItem("isAuthenticated", "true");
sessionStorage.setItem("role", data.role || "guest");
// Dispatch a custom event with the new role
const event = new CustomEvent("auth-success", {
bubbles: true,
composed: true,
detail: { role: data.role }
});
document.dispatchEvent(event);
setAuthData(data.token);
onSuccess?.();
} catch (err: any) {
setError(err instanceof Error ? err.message : "Authentication failed");
@ -89,7 +81,7 @@ const SignIn = ({ onSuccess, requiredRole = "guest" }: SignInProps) => {
<button
type="submit"
className="btn btn-primary w-full"
disabled={!code || isSubmitting}
disabled={isDisabled}
>
{isSubmitting ? "Verifying..." : "Continue"}
</button>

View File

@ -1,9 +1,10 @@
<button class="btn btn-secondary" onclick="handleSignOut()">Sign Out</button>
<button id="signout-btn" class="btn btn-secondary">Sign Out</button>
<script>
function handleSignOut() {
sessionStorage.removeItem("isAuthenticated");
sessionStorage.removeItem("role");
window.location.reload();
}
import { clearAuthData } from "../utils/auth-client";
// Add click handler to the button
document.getElementById("signout-btn")?.addEventListener("click", () => {
clearAuthData();
});
</script>

View File

@ -1,30 +1,24 @@
import React, { useEffect, useState } from "react";
import { clearAuthData, isAuthenticated } from "../utils/auth-client";
const SignOut = () => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const isAuthenticated = sessionStorage.getItem("isAuthenticated") === "true";
setIsVisible(isAuthenticated);
setIsVisible(isAuthenticated());
const handleAuthChange = () => {
setIsVisible(sessionStorage.getItem("isAuthenticated") === "true");
setIsVisible(isAuthenticated());
};
document.addEventListener("auth-success", handleAuthChange);
return () => document.removeEventListener("auth-success", handleAuthChange);
}, []);
const handleSignOut = () => {
sessionStorage.removeItem("isAuthenticated");
sessionStorage.removeItem("role");
window.location.reload();
};
if (!isVisible) return null;
return (
<button onClick={handleSignOut} className="btn btn-secondary">
<button onClick={clearAuthData} className="btn btn-secondary">
Sign Out
</button>
);

View File

@ -8,7 +8,7 @@ interface Props {
const { title } = Astro.props;
---
<div id="auth-container" class="flex flex-col gap-4">
<div id="auth-container">
<SignIn client:load onSuccess={() => {}} requiredRole="guest" />
</div>
@ -17,16 +17,24 @@ const { title } = Astro.props;
</div>
<script>
// Check auth state on page load
const isAuthenticated = sessionStorage.getItem("isAuthenticated") === "true";
if (isAuthenticated) {
document.getElementById("auth-container")?.classList.add("hidden");
document.getElementById("content-container")?.classList.remove("hidden");
import { isAuthenticated } from "../utils/auth-client";
function updateVisibility() {
const authContainer = document.getElementById("auth-container");
const contentContainer = document.getElementById("content-container");
if (isAuthenticated()) {
authContainer?.classList.add("hidden");
contentContainer?.classList.remove("hidden");
} else {
authContainer?.classList.remove("hidden");
contentContainer?.classList.add("hidden");
}
}
// Check auth state on page load
updateVisibility();
// Add event listener for custom event from SignIn component
document.addEventListener("auth-success", () => {
document.getElementById("auth-container")?.classList.add("hidden");
document.getElementById("content-container")?.classList.remove("hidden");
});
document.addEventListener("auth-success", updateVisibility);
</script>

View File

@ -1,6 +1,5 @@
---
import "../styles/global.css";
import AuthLayout from "./AuthLayout.astro";
import Navigation from "../components/Navigation.astro";
interface Props {
@ -26,9 +25,7 @@ const { title } = Astro.props;
<body>
<Navigation />
<div class="flex items-center justify-center min-h-screen">
<AuthLayout title={title}>
<slot />
</AuthLayout>
<slot />
</div>
</body>
</html>

View File

@ -1,4 +1,5 @@
import type { APIRoute } from "astro";
import { generateToken } from "../../utils/jwt";
interface AuthRequest {
code: string;
@ -15,11 +16,15 @@ export const POST: APIRoute = async ({ request }) => {
const secretCode = process.env.SECRET_CODE || import.meta.env.SECRET_CODE;
const adminCode = process.env.ADMIN_CODE || import.meta.env.ADMIN_CODE;
let role = "guest";
let role: "guest" | "admin" | null = null;
if (code === adminCode) {
role = "admin";
} else if (code !== secretCode) {
} else if (code === secretCode) {
role = "guest";
}
if (!role) {
return new Response(
JSON.stringify({
success: false,
@ -45,10 +50,19 @@ export const POST: APIRoute = async ({ request }) => {
);
}
return new Response(JSON.stringify({ success: true, role }), {
status: 200,
headers,
});
const token = generateToken(role);
return new Response(
JSON.stringify({
success: true,
token,
role
}),
{
status: 200,
headers,
}
);
} catch (error) {
console.error("Authentication failed:", error);
return new Response(

View File

@ -1,11 +1,12 @@
import type { APIRoute } from "astro";
import { getS3Data, putS3Data } from "../../../lib/s3";
import type { RegistryItem } from "../../../lib/types";
import { createProtectedAPIRoute } from "../../../utils/auth-middleware";
const REGISTRY_FILE_KEY = "registry.json";
// GET: Get a specific registry item by ID
export const GET: APIRoute = async ({ params }) => {
// GET: Get a specific registry item by ID (requires guest role)
const handleGet: APIRoute = async ({ params }) => {
try {
const { id } = params;
if (!id) {
@ -38,8 +39,8 @@ export const GET: APIRoute = async ({ params }) => {
}
};
// PUT: Update an existing registry item
export const PUT: APIRoute = async ({ request, params }) => {
// PUT: Update an existing registry item (requires guest role)
const handlePut: APIRoute = async ({ request, params }) => {
try {
const { id } = params;
if (!id) {
@ -83,9 +84,8 @@ export const PUT: APIRoute = async ({ request, params }) => {
}
};
// DELETE: Delete a registry item
export const DELETE: APIRoute = async ({ params }) => {
// DELETE: Delete a registry item (requires admin role)
const handleDelete: APIRoute = async ({ params }) => {
const headers = {
"Content-Type": "application/json",
};
@ -134,3 +134,8 @@ export const DELETE: APIRoute = async ({ params }) => {
);
}
};
// Export protected routes
export const GET = createProtectedAPIRoute(handleGet, "guest");
export const PUT = createProtectedAPIRoute(handlePut, "guest");
export const DELETE = createProtectedAPIRoute(handleDelete, "admin");

View File

@ -2,11 +2,12 @@ import type { APIRoute } from "astro";
import { getS3Data, putS3Data } from "../../../lib/s3";
import { v4 as uuidv4 } from "uuid";
import type { RegistryItem } from "../../../lib/types";
import { createProtectedAPIRoute } from "../../../utils/auth-middleware";
const REGISTRY_FILE_KEY = "registry.json";
// GET: List all registry items
export const GET: APIRoute = async () => {
// GET: List all registry items (requires guest role)
const handleGet: APIRoute = async () => {
try {
const registry = await getS3Data<RegistryItem[]>(REGISTRY_FILE_KEY) || [];
return new Response(JSON.stringify(registry), {
@ -22,8 +23,8 @@ export const GET: APIRoute = async () => {
}
};
// POST: Create a new registry item
export const POST: APIRoute = async ({ request }) => {
// POST: Create a new registry item (requires admin role)
const handlePost: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { name, link } = body;
@ -58,3 +59,7 @@ export const POST: APIRoute = async ({ request }) => {
);
}
};
// Export protected routes
export const GET = createProtectedAPIRoute(handleGet, "guest");
export const POST = createProtectedAPIRoute(handlePost, "admin");

View File

@ -1,6 +1,7 @@
import type { APIRoute } from "astro";
import { getS3Data, putS3Data } from "../../lib/s3";
import type { RSVPItem } from "../../lib/types";
import { createProtectedAPIRoute } from "../../utils/auth-middleware";
const objectsToCSV = (data: RSVPItem[]): string => {
const headers = ["name", "dietaryRestrictions", "notes", "timestamp"];
@ -44,8 +45,8 @@ const csvToObjects = (csv: string): RSVPItem[] => {
});
};
// GET: Retrieve all RSVPs
export const GET: APIRoute = async ({ request }) => {
// GET: Retrieve all RSVPs (requires admin role)
const handleGet: APIRoute = async ({ request }) => {
const headers = {
"Content-Type": "application/json",
};
@ -81,8 +82,8 @@ export const GET: APIRoute = async ({ request }) => {
}
};
// POST: Submit a new RSVP
export const POST: APIRoute = async ({ request }) => {
// POST: Submit a new RSVP (requires guest role)
const handlePost: APIRoute = async ({ request }) => {
console.log("API endpoint hit - starting request processing");
const headers = {
@ -136,3 +137,7 @@ export const POST: APIRoute = async ({ request }) => {
);
}
};
// Export protected routes
export const GET = createProtectedAPIRoute(handleGet, "admin");
export const POST = createProtectedAPIRoute(handlePost, "guest");

View File

@ -15,40 +15,33 @@ import SignOut from "../../components/SignOut.tsx";
<div id="manager-container" class="hidden">
<RegistryManager client:load />
</div>
<div class="flex flex-row gap-2 justify-center items-center">
<a class="btn btn-primary" href="/">Back to Home</a>
<div class="flex flex-row gap-2 justify-center items-center mt-4">
<a class="btn btn-primary" href="/">Back to Home</a>
<SignOut client:load />
</div>
</div>
</div>
</AdminLayout>
<script>
const checkAndUpdateVisibility = (role: string | null) => {
if (role === "admin") {
document.getElementById("auth-container")?.classList.add("hidden");
document
.getElementById("manager-container")
?.classList.remove("hidden");
import { hasRole } from "../../utils/auth-client";
function updateVisibility() {
const authContainer = document.getElementById("auth-container");
const managerContainer = document.getElementById("manager-container");
if (hasRole("admin")) {
authContainer?.classList.add("hidden");
managerContainer?.classList.remove("hidden");
} else {
document
.getElementById("auth-container")
?.classList.remove("hidden");
document
.getElementById("manager-container")
?.classList.add("hidden");
authContainer?.classList.remove("hidden");
managerContainer?.classList.add("hidden");
}
};
}
// Check auth state on page load
const isAuthenticated =
sessionStorage.getItem("isAuthenticated") === "true";
const role = sessionStorage.getItem("role");
checkAndUpdateVisibility(role);
updateVisibility();
// Add event listener for custom event from SignIn component
document.addEventListener("auth-success", ((event: CustomEvent) => {
const newRole = event.detail?.role || sessionStorage.getItem("role");
checkAndUpdateVisibility(newRole);
}) as EventListener);
document.addEventListener("auth-success", updateVisibility);
</script>

View File

@ -1,6 +1,7 @@
---
import Layout from "../../layouts/Layout.astro";
import RegistryList from "../../components/RegistryList.tsx";
import SignIn from "../../components/SignIn.tsx";
import SignOut from "../../components/SignOut.tsx";
---
@ -10,37 +11,44 @@ import SignOut from "../../components/SignOut.tsx";
View and Claim Items from the Registry:
</div>
<div id="registry-container">
<div id="auth-container">
<SignIn client:load onSuccess={() => {}} requiredRole="guest" />
</div>
<div id="content-container" class="hidden">
<RegistryList client:load />
<div id="empty-registry-message" class="text-center p-8 hidden">
<p class="text-xl">Nothing here yet! Please check back in a week!</p>
</div>
</div>
<div class="flex flex-row gap-2 justify-center items-center">
<a class="btn btn-primary" href="/">Back to Home</a>
<div class="flex flex-row gap-2 justify-center items-center mt-4">
<a class="btn btn-primary" href="/">Back to Home</a>
<SignOut client:load />
</div>
</div>
</div>
</Layout>
<script>
// Check auth state on page load
const isAuthenticated =
sessionStorage.getItem("isAuthenticated") === "true";
if (isAuthenticated) {
document.getElementById("auth-container")?.classList.add("hidden");
document
.getElementById("registry-container")
?.classList.remove("hidden");
import { hasRole, isAuthenticated } from "../../utils/auth-client";
function updateVisibility() {
const authContainer = document.getElementById("auth-container");
const contentContainer = document.getElementById("content-container");
if (isAuthenticated() && hasRole("guest")) {
authContainer?.classList.add("hidden");
contentContainer?.classList.remove("hidden");
} else {
authContainer?.classList.remove("hidden");
contentContainer?.classList.add("hidden");
}
}
// Check auth state on page load
updateVisibility();
// Add event listener for custom event from SignIn component
document.addEventListener("auth-success", () => {
document.getElementById("auth-container")?.classList.add("hidden");
document
.getElementById("registry-container")
?.classList.remove("hidden");
});
document.addEventListener("auth-success", updateVisibility);
// Check for empty registry
document.addEventListener("registry-empty", () => {

View File

@ -1,6 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
import RSVP from "../components/RSVP.tsx";
import SignIn from "../components/SignIn.tsx";
import SignOut from "../components/SignOut.tsx";
---
@ -10,26 +11,39 @@ import SignOut from "../components/SignOut.tsx";
Please RSVP using the form below:
</div>
<RSVP client:load />
<div id="auth-container">
<SignIn client:load onSuccess={() => {}} requiredRole="guest" />
</div>
<div class="flex flex-row gap-2 justify-center items-center">
<a class="btn btn-primary" href="/">Back to Home</a>
<div id="content-container" class="hidden">
<RSVP client:load />
<div class="flex flex-row gap-2 justify-center items-center mt-4">
<a class="btn btn-primary" href="/">Back to Home</a>
<SignOut client:load />
</div>
</div>
</div>
</Layout>
<script>
// Check auth state on page load
const isAuthenticated =
sessionStorage.getItem("isAuthenticated") === "true";
if (isAuthenticated) {
document.getElementById("auth-container")?.classList.add("hidden");
document.getElementById("rsvp-container")?.classList.remove("hidden");
import { hasRole, isAuthenticated } from "../utils/auth-client";
function updateVisibility() {
const authContainer = document.getElementById("auth-container");
const contentContainer = document.getElementById("content-container");
if (isAuthenticated() && hasRole("guest")) {
authContainer?.classList.add("hidden");
contentContainer?.classList.remove("hidden");
} else {
authContainer?.classList.remove("hidden");
contentContainer?.classList.add("hidden");
}
}
// Check auth state on page load
updateVisibility();
// Add event listener for custom event from SignIn component
document.addEventListener("auth-success", () => {
document.getElementById("auth-container")?.classList.add("hidden");
document.getElementById("rsvp-container")?.classList.remove("hidden");
});
document.addEventListener("auth-success", updateVisibility);
</script>

View File

@ -15,40 +15,33 @@ import SignOut from "../../components/SignOut.tsx";
<div id="manager-container" class="hidden">
<RSVPManager client:load />
</div>
<div class="flex flex-row gap-2 justify-center items-center">
<a class="btn btn-primary" href="/">Back to Home</a>
<div class="flex flex-row gap-2 justify-center items-center mt-4">
<a class="btn btn-primary" href="/">Back to Home</a>
<SignOut client:load />
</div>
</div>
</div>
</AdminLayout>
<script>
const checkAndUpdateVisibility = (role: string | null) => {
if (role === "admin") {
document.getElementById("auth-container")?.classList.add("hidden");
document
.getElementById("manager-container")
?.classList.remove("hidden");
import { hasRole } from "../../utils/auth-client";
function updateVisibility() {
const authContainer = document.getElementById("auth-container");
const managerContainer = document.getElementById("manager-container");
if (hasRole("admin")) {
authContainer?.classList.add("hidden");
managerContainer?.classList.remove("hidden");
} else {
document
.getElementById("auth-container")
?.classList.remove("hidden");
document
.getElementById("manager-container")
?.classList.add("hidden");
authContainer?.classList.remove("hidden");
managerContainer?.classList.add("hidden");
}
};
}
// Check auth state on page load
const isAuthenticated =
sessionStorage.getItem("isAuthenticated") === "true";
const role = sessionStorage.getItem("role");
checkAndUpdateVisibility(role);
updateVisibility();
// Add event listener for custom event from SignIn component
document.addEventListener("auth-success", ((event: CustomEvent) => {
const newRole = event.detail?.role || sessionStorage.getItem("role");
checkAndUpdateVisibility(newRole);
}) as EventListener);
document.addEventListener("auth-success", updateVisibility);
</script>

View File

@ -0,0 +1,49 @@
---
import Layout from "../../layouts/Layout.astro";
import RSVP from "../../components/RSVP.tsx";
import SignIn from "../../components/SignIn.tsx";
import SignOut from "../../components/SignOut.tsx";
---
<Layout title="RSVP">
<div class="flex flex-col gap-4">
<div class="text-center text-4xl">
Please RSVP using the form below:
</div>
<div id="auth-container">
<SignIn client:load onSuccess={() => {}} requiredRole="guest" />
</div>
<div id="content-container" class="hidden">
<RSVP client:load />
<div class="flex flex-row gap-2 justify-center items-center mt-4">
<a class="btn btn-primary" href="/">Back to Home</a>
<SignOut client:load />
</div>
</div>
</div>
</Layout>
<script>
import { hasRole } from "../../utils/auth-client";
function updateVisibility() {
const authContainer = document.getElementById("auth-container");
const contentContainer = document.getElementById("content-container");
if (hasRole("guest")) {
authContainer?.classList.add("hidden");
contentContainer?.classList.remove("hidden");
} else {
authContainer?.classList.remove("hidden");
contentContainer?.classList.add("hidden");
}
}
// Check auth state on page load
updateVisibility();
// Add event listener for custom event from SignIn component
document.addEventListener("auth-success", updateVisibility);
</script>

77
src/utils/auth-client.ts Normal file
View File

@ -0,0 +1,77 @@
import { jwtDecode } from "jwt-decode";
interface JWTPayload {
role: string;
iat?: number;
exp?: number;
}
export function setAuthData(token: string) {
sessionStorage.setItem('authToken', token);
// Dispatch auth event
const payload = jwtDecode<JWTPayload>(token);
const event = new CustomEvent("auth-success", {
bubbles: true,
composed: true,
detail: { role: payload?.role }
});
document.dispatchEvent(event);
}
export function clearAuthData() {
sessionStorage.removeItem('authToken');
window.location.reload();
}
export function getAuthToken(): string | null {
return sessionStorage.getItem('authToken');
}
export function getRole(): string | null {
const token = getAuthToken();
if (!token) return null;
try {
const payload = jwtDecode<JWTPayload>(token);
// Check if token is expired
if (payload.exp && payload.exp * 1000 < Date.now()) {
clearAuthData(); // Clear expired token
return null;
}
return payload.role;
} catch (error) {
console.error('Error decoding token:', error);
clearAuthData(); // Clear invalid token
return null;
}
}
export function isAuthenticated(): boolean {
return getRole() !== null;
}
export function hasRole(requiredRole: "guest" | "admin"): boolean {
const role = getRole();
if (!role) return false;
if (requiredRole === "admin") return role === "admin";
if (requiredRole === "guest") return role === "guest" || role === "admin";
return false;
} // admin can access guest pages, but guest can't access admin pages
export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const token = getAuthToken();
if (!token) {
throw new Error('No authentication token found');
}
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`,
};
return fetch(url, {
...options,
headers,
});
}

View File

@ -0,0 +1,50 @@
import type { APIContext, APIRoute } from "astro";
import { verifyToken, extractTokenFromHeader } from "./jwt";
export async function validateAuth(
context: APIContext,
requiredRole?: "guest" | "admin"
): Promise<{ isValid: boolean; role?: string; error?: string }> {
const authHeader = context.request.headers.get("Authorization");
const token = extractTokenFromHeader(authHeader || "");
if (!token) {
return { isValid: false, error: "No token provided" };
}
const payload = verifyToken(token);
if (!payload) {
return { isValid: false, error: "Invalid token" };
}
if (requiredRole && payload.role !== requiredRole) {
if (requiredRole === "admin" && payload.role !== "admin") {
return { isValid: false, error: "Admin access required" };
}
}
return { isValid: true, role: payload.role };
}
export function createProtectedAPIRoute(handler: Function, requiredRole?: "guest" | "admin"): APIRoute {
return async (context: APIContext) => {
const { isValid, error } = await validateAuth(context, requiredRole);
if (!isValid) {
return new Response(
JSON.stringify({
success: false,
error: error || "Authentication failed",
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
},
}
);
}
return handler(context);
};
}

33
src/utils/jwt.ts Normal file
View File

@ -0,0 +1,33 @@
import jwt from 'jsonwebtoken';
interface JWTPayload {
role: string;
iat?: number;
exp?: number;
}
const JWT_SECRET = process.env.JWT_SECRET || import.meta.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is not set');
}
export function generateToken(role: string): string {
return jwt.sign({ role }, JWT_SECRET, { expiresIn: '24h' });
}
export function verifyToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as JWTPayload;
} catch (error) {
console.error('JWT verification failed:', error);
return null;
}
}
export function extractTokenFromHeader(authHeader?: string): string | null {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.split(' ')[1];
}