diff --git a/.env.example b/.env.example index 46cbbae..a5580c6 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,5 @@ S3_ACCESS_KEY=your_access_key_here S3_SECRET_KEY=your_secret_key_here S3_BUCKET_NAME=your_bucket_name SECRET_CODE=super_secret_code -ADMIN_CODE=super_secret_code \ No newline at end of file +ADMIN_CODE=super_secret_code +JWT_SECRET=your_jwt_secret_key_here_make_it_long_and_random \ No newline at end of file diff --git a/package.json b/package.json index 22bf358..147d777 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "@astrojs/react": "^4.2.0", "@aws-sdk/client-s3": "^3.750.0", "@tailwindcss/vite": "^4.0.8", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^22.13.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "astro": "^5.3.1", "astro-robots": "^2.3.1", + "jsonwebtoken": "^9.0.2", + "jwt-decode": "^4.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7207e52..51e77b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.8 version: 4.0.8(vite@6.1.1(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)) + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.9 '@types/node': specifier: ^22.13.5 version: 22.13.5 @@ -35,6 +38,12 @@ importers: astro-robots: specifier: ^2.3.1 version: 2.3.1(astro@5.3.1(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(rollup@4.34.8)(typescript@5.7.3)) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 react: specifier: ^19.0.0 version: 19.0.0 @@ -1040,6 +1049,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsonwebtoken@9.0.9': + resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1145,6 +1157,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -1288,6 +1303,9 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1522,6 +1540,20 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -1602,6 +1634,27 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -1950,6 +2003,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -3660,6 +3716,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jsonwebtoken@9.0.9': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.13.5 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -3855,6 +3916,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.2(browserslist@4.24.4) + buffer-equal-constant-time@1.0.1: {} + camelcase@8.0.0: {} caniuse-lite@1.0.30001700: {} @@ -3958,6 +4021,10 @@ snapshots: dset@3.1.4: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.104: {} @@ -4223,6 +4290,32 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.1 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -4283,6 +4376,20 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + longest-streak@3.1.0: {} lru-cache@10.4.3: {} @@ -4874,6 +4981,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.8 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + scheduler@0.25.0: {} semver@6.3.1: {} diff --git a/src/components/RSVP.tsx b/src/components/RSVP.tsx index 9aba9a4..d183dff 100644 --- a/src/components/RSVP.tsx +++ b/src/components/RSVP.tsx @@ -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", diff --git a/src/components/RSVPManager.tsx b/src/components/RSVPManager.tsx index c13703d..adb2ecf 100644 --- a/src/components/RSVPManager.tsx +++ b/src/components/RSVPManager.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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
Loading RSVP list...
; } if (error) { + if (error === "No authentication token found") { + return
Initializing...
; + } return
Error: {error}
; } diff --git a/src/components/RegistryList.tsx b/src/components/RegistryList.tsx index 29637e8..766f305 100644 --- a/src/components/RegistryList.tsx +++ b/src/components/RegistryList.tsx @@ -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([]); - const [claimedItems, setClaimedItems] = useState>(new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [claimantName, setClaimantName] = useState(""); - const [showClaimants, setShowClaimants] = useState>(new Set()); + const [claimedItems, setClaimedItems] = useState>(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
Error: {error}
; } + const availableItems = registryItems.filter((item) => !item.taken); + const claimedItemsArray = registryItems.filter((item) => item.taken); + return (
-

Wedding Registry

-
- - - - - - - - - - - {registryItems.map((item) => ( - - - - - - + + + ))} + + + {claimedItems.size > 0 && ( +
+ +
+ )} + + )} + + {claimedItemsArray.length > 0 && ( +
+

Already Claimed

+
+ {claimedItemsArray.map((item) => ( +
+
+

{item.name}

+

+ Claimed by: {item.claimedBy} +

+
+
))} -
-
ItemLinkStatusClaim
-
-
- {item.name} - {item.taken && ( -
- Taken - -
- )} -
- {item.taken && - item.claimedBy && - showClaimants.has(item.id) && ( -
- Claimed by: {item.claimedBy} -
- )} -
-
+ {availableItems.length > 0 && ( + <> +
+ setClaimantName(e.target.value)} + placeholder="Enter your name" + className="input input-bordered w-full max-w-xs" + /> +
+ +
+ {availableItems.map((item) => ( +
handleItemClick(item.id)} + > +
+

{item.name}

{item.link && ( e.stopPropagation()} > - View + View Item )} -
{item.taken ? "Claimed" : "Available"} - {!item.taken ? ( - handleCheckboxChange(item.id)} - /> - ) : } -
-
- {claimedItems.size > 0 && ( -
-
- - setClaimantName(e.target.value)} - placeholder="Enter your name" - required - />
-
)}
diff --git a/src/components/RegistryManager.tsx b/src/components/RegistryManager.tsx index f7511e5..f3c9f89 100644 --- a/src/components/RegistryManager.tsx +++ b/src/components/RegistryManager.tsx @@ -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([]); @@ -8,20 +9,24 @@ const RegistryManager = () => { const [newItem, setNewItem] = useState({ name: "", link: "" }); const [showClaimants, setShowClaimants] = useState>(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
Initializing...
; + } return
Error: {error}
; } return (
-
-
-

Add New Item

-
- - - setNewItem({ ...newItem, name: e.target.value }) - } - required - /> -
-
- - - setNewItem({ ...newItem, link: e.target.value }) - } - /> -
-
- -
-
+
+ setNewItem({ ...newItem, name: e.target.value })} + placeholder="Item name" + className="input input-bordered" + required + /> + setNewItem({ ...newItem, link: e.target.value })} + placeholder="Item link (optional)" + className="input input-bordered" + /> +
@@ -140,7 +138,7 @@ const RegistryManager = () => { - + @@ -149,35 +147,7 @@ const RegistryManager = () => { {registryItems.map((item) => ( - + - +
ItemName Link Status Actions
-
- {item.name} - {item.taken && ( - <> - Taken - {item.claimedBy && ( - - )} - - )} -
- {item.taken && - item.claimedBy && - showClaimants.has(item.id) && ( -
- Claimed by: {item.claimedBy} -
- )} -
{item.name} {item.link && ( { rel="noopener noreferrer" className="link link-primary" > - View + View Item )} {item.taken ? "Claimed" : "Available"} +
+ + {item.taken ? "Claimed" : "Available"} + + {item.taken && ( + + )} +
+ {item.taken && showClaimants.has(item.id) && ( +
+ Claimed by: {item.claimedBy} +
+ )} +
diff --git a/src/components/SignIn.tsx b/src/components/SignIn.tsx index a294ab5..4cb6a36 100644 --- a/src/components/SignIn.tsx +++ b/src/components/SignIn.tsx @@ -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(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) => { diff --git a/src/components/SignOut.astro b/src/components/SignOut.astro index e5b94d2..dce52cc 100644 --- a/src/components/SignOut.astro +++ b/src/components/SignOut.astro @@ -1,9 +1,10 @@ - + \ No newline at end of file diff --git a/src/components/SignOut.tsx b/src/components/SignOut.tsx index a5d0baa..8904e9b 100644 --- a/src/components/SignOut.tsx +++ b/src/components/SignOut.tsx @@ -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 ( - ); diff --git a/src/layouts/AuthLayout.astro b/src/layouts/AuthLayout.astro index eb09196..680aade 100644 --- a/src/layouts/AuthLayout.astro +++ b/src/layouts/AuthLayout.astro @@ -8,7 +8,7 @@ interface Props { const { title } = Astro.props; --- -
+
{}} requiredRole="guest" />
@@ -17,16 +17,24 @@ const { title } = Astro.props;
\ No newline at end of file diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 17de210..3ef6c54 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -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;
- - - +
diff --git a/src/pages/api/auth.ts b/src/pages/api/auth.ts index b84131e..c20529f 100644 --- a/src/pages/api/auth.ts +++ b/src/pages/api/auth.ts @@ -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( diff --git a/src/pages/api/registry/[id].ts b/src/pages/api/registry/[id].ts index 6d5db75..60f0834 100644 --- a/src/pages/api/registry/[id].ts +++ b/src/pages/api/registry/[id].ts @@ -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"); diff --git a/src/pages/api/registry/index.ts b/src/pages/api/registry/index.ts index 131b033..4cf07a3 100644 --- a/src/pages/api/registry/index.ts +++ b/src/pages/api/registry/index.ts @@ -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(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"); diff --git a/src/pages/api/rsvp.ts b/src/pages/api/rsvp.ts index 9ad46d6..15b45e9 100644 --- a/src/pages/api/rsvp.ts +++ b/src/pages/api/rsvp.ts @@ -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"); diff --git a/src/pages/registry/admin.astro b/src/pages/registry/admin.astro index 8f2e0af..761418d 100644 --- a/src/pages/registry/admin.astro +++ b/src/pages/registry/admin.astro @@ -15,40 +15,33 @@ import SignOut from "../../components/SignOut.tsx"; - - diff --git a/src/pages/registry/index.astro b/src/pages/registry/index.astro index 3995116..4905ba5 100644 --- a/src/pages/registry/index.astro +++ b/src/pages/registry/index.astro @@ -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: -
+
+ {}} requiredRole="guest" /> +
+ + - -
diff --git a/src/pages/rsvp/admin.astro b/src/pages/rsvp/admin.astro index 0d162df..0379884 100644 --- a/src/pages/rsvp/admin.astro +++ b/src/pages/rsvp/admin.astro @@ -15,40 +15,33 @@ import SignOut from "../../components/SignOut.tsx"; - - \ No newline at end of file diff --git a/src/pages/rsvp/index.astro b/src/pages/rsvp/index.astro new file mode 100644 index 0000000..6d2d812 --- /dev/null +++ b/src/pages/rsvp/index.astro @@ -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"; +--- + + +
+
+ Please RSVP using the form below: +
+ +
+ {}} requiredRole="guest" /> +
+ + +
+
+ + \ No newline at end of file diff --git a/src/utils/auth-client.ts b/src/utils/auth-client.ts new file mode 100644 index 0000000..20f2e86 --- /dev/null +++ b/src/utils/auth-client.ts @@ -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(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(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 { + const token = getAuthToken(); + if (!token) { + throw new Error('No authentication token found'); + } + + const headers = { + ...options.headers, + 'Authorization': `Bearer ${token}`, + }; + + return fetch(url, { + ...options, + headers, + }); +} \ No newline at end of file diff --git a/src/utils/auth-middleware.ts b/src/utils/auth-middleware.ts new file mode 100644 index 0000000..7805259 --- /dev/null +++ b/src/utils/auth-middleware.ts @@ -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); + }; +} \ No newline at end of file diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000..b83bb6a --- /dev/null +++ b/src/utils/jwt.ts @@ -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]; +} \ No newline at end of file