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

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

View File

@ -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
View File

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

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];
}