Fixed auth security
This commit is contained in:
@ -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
|
||||
ADMIN_CODE=super_secret_code
|
||||
JWT_SECRET=your_jwt_secret_key_here_make_it_long_and_random
|
@ -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",
|
||||
|
109
pnpm-lock.yaml
generated
109
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
@ -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",
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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(
|
||||
|
@ -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");
|
||||
|
@ -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");
|
||||
|
@ -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");
|
||||
|
@ -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>
|
||||
|
@ -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", () => {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
49
src/pages/rsvp/index.astro
Normal file
49
src/pages/rsvp/index.astro
Normal 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
77
src/utils/auth-client.ts
Normal 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,
|
||||
});
|
||||
}
|
50
src/utils/auth-middleware.ts
Normal file
50
src/utils/auth-middleware.ts
Normal 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
33
src/utils/jwt.ts
Normal 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];
|
||||
}
|
Reference in New Issue
Block a user