Compare commits

...

18 Commits

Author SHA1 Message Date
belisards
2418ab64cc chore(deploy): switch domain to chadomartin.omeu.website
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-03 16:53:51 -03:00
belisards
c959cc8829 feat(ui): hide spoiler banner, anonymize claims, optional guest name, list wishlists on home, drop esgotados toggle 2026-05-03 16:53:48 -03:00
belisards
ce7731cebb chore: remove password-lock; switch to ADMIN_TOKEN env; update docs 2026-05-03 16:34:41 -03:00
belisards
ea0d2c9370 feat(cli): guest create/list/delete scripts 2026-05-03 16:33:10 -03:00
belisards
0832c0f9e5 feat(admin): guests CRUD UI with copy-link 2026-05-03 16:32:19 -03:00
belisards
8d9e7c0709 feat(home): show convite necessário; route admin/guest tokens 2026-05-03 16:31:40 -03:00
belisards
4f3017a02d refactor(auth): replace JWT/password-lock with token guards 2026-05-03 16:31:00 -03:00
belisards
7b29e39e9f feat(client): swap auth client to session API; add guestsApi 2026-05-03 16:26:09 -03:00
belisards
e518e28957 feat(api): tokens on all routes; items expose claims/claimedQuantity/remainingQuantity 2026-05-03 16:25:19 -03:00
belisards
844951c832 feat(claim): quantity-aware claims via item_claims; per-guest unclaim 2026-05-03 16:22:22 -03:00
belisards
32f9403bd8 feat(api): admin guests CRUD endpoints 2026-05-03 16:21:26 -03:00
belisards
7b2e2cc3c5 feat(auth): replace login/refresh/me with /api/auth/session 2026-05-03 16:20:37 -03:00
belisards
f03e7aaf19 feat(auth): add token verification and cookie helpers 2026-05-03 16:19:14 -03:00
belisards
5596e3fa19 feat(db): introduce item_claims for quantity-aware claims; supersede inline claim columns 2026-05-03 16:18:34 -03:00
belisards
44d4dcbf7e feat(db): add guests table and claimed_by_guest_id column 2026-05-03 16:12:24 -03:00
belisards
4d4cdee9eb wip: in-progress mars theme work (saved before auth refactor) 2026-05-03 16:11:12 -03:00
belisards
282e475562 feat(theme): switch to Mars-inspired palette and atmospheric haze (no planet disk) 2026-05-03 15:49:30 -03:00
belisards
e38473e88d feat(home): inline items grid, drop share button, add planet/newborn theme, rename to Chá do Martin 2026-05-03 15:47:56 -03:00
50 changed files with 1312 additions and 1772 deletions

View File

@@ -1,12 +1,10 @@
# Admin Credentials (REQUIRED)
# Set a strong username and password for the admin account
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
# Admin Token (REQUIRED)
# Long random string used to authenticate the admin via ?adm=<token> in the URL.
# Generate with: openssl rand -hex 32
ADMIN_TOKEN=replace-with-32-byte-random-hex
# JWT Secret (Optional - auto-generated if not provided)
# For production, generate a secure random string:
# openssl rand -base64 32
SECRET=
# Public base URL used by the guest CLI when generating links
PUBLIC_BASE_URL=http://localhost:3000
# Application Settings (Optional)
NODE_ENV=production

View File

@@ -49,17 +49,28 @@ UI strings are hardcoded in the source. The PT-BR localization lives in:
- `app/layout.tsx``<html lang="pt-BR">` and metadata description
- `app/[slug]/page.tsx``Intl.NumberFormat('pt-BR', ...)` for prices
- `components/share-button.tsx` — "Compartilhar" label
- `app/lock/page.tsx`, `app/not-found.tsx`, `app/page.tsx` — page copy
- `app/not-found.tsx`, `app/page.tsx` — page copy
To switch language, update those files and rebuild the image.
### Admin credentials
### Admin token
Set via environment variables in `docker-compose.yml`:
The admin authenticates by visiting the site with `?adm=<token>` once. The token is set as an env var:
```yaml
- ADMIN_USERNAME=your_username
- ADMIN_PASSWORD=your_password
- ADMIN_TOKEN=long-random-hex-at-least-16-chars
```
The token is also accepted from the cookie `adm_token` after first visit.
### Guests
Each guest gets their own URL: `https://chadebebe.omeu.website/<slug>?usr=<token>`. Manage guests at `/admin/guests` or via CLI:
```bash
npm run guest:create -- --name="Martin"
npm run guest:list
npm run guest:delete -- --id=<guest-id>
```
## Deployment
@@ -80,7 +91,7 @@ cd chadebebe
docker compose up -d --build
```
The `data/db/wishlist.db` snapshot committed in the repo will be used as the initial database. On first run the app writes `data/secrets.json` (JWT keys) — this file is gitignored and must not be committed.
The `data/db/wishlist.db` snapshot committed in the repo will be used as the initial database.
### Updating the app

View File

@@ -3,30 +3,55 @@
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import DOMPurify from 'dompurify';
import { wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api';
import { authApi, wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api';
import Header from '@/components/header';
import Footer from '@/components/footer';
import PasswordLockGuard from '@/components/password-lock-guard';
import GuestGuard from '@/components/guest-guard';
export default function PublicWishlistPage() {
return (
<GuestGuard>
<PublicWishlistContent />
</GuestGuard>
);
}
function PublicWishlistContent() {
const params = useParams();
const [wishlist, setWishlist] = useState<Wishlist | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [showClaimed, setShowClaimed] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
// Current viewer
const [currentGuestId, setCurrentGuestId] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
// Claim form state
const [claimingItemId, setClaimingItemId] = useState<string | null>(null);
const [claimNote, setClaimNote] = useState('');
const [claimQty, setClaimQty] = useState(1);
const [isClaiming, setIsClaiming] = useState(false);
const [claimError, setClaimError] = useState('');
const [justClaimedItemId, setJustClaimedItemId] = useState<string | null>(null);
const [justClaimedNote, setJustClaimedNote] = useState('');
// Unclaim state
const [isUnclaiming, setIsUnclaiming] = useState(false);
const [unclaimError, setUnclaimError] = useState('');
useEffect(() => {
(async () => {
try {
const who = await authApi.whoami();
if (who.role === 'admin') {
setIsAdmin(true);
if (who.guest) setCurrentGuestId(who.guest.id);
} else if (who.role === 'guest') {
setCurrentGuestId(who.guest.id);
}
} catch {
/* ignore */
}
})();
}, []);
useEffect(() => {
fetchWishlist();
@@ -48,10 +73,14 @@ export default function PublicWishlistPage() {
}
};
const handleClaimItem = (itemId: string) => {
setClaimingItemId(itemId);
const myClaimFor = (item: Item) => item.claims.find((c) => c.guest.id === currentGuestId);
const handleStartClaim = (item: Item) => {
const my = myClaimFor(item);
setClaimingItemId(item.id);
setClaimError('');
setClaimNote('');
setClaimNote(my?.note ?? '');
setClaimQty(my?.quantity ?? 1);
setJustClaimedItemId(null);
};
@@ -62,12 +91,12 @@ export default function PublicWishlistPage() {
setClaimError('');
try {
await claimingApi.claim(itemId, undefined, claimNote);
await claimingApi.claim(itemId, { quantity: claimQty, note: claimNote });
setJustClaimedItemId(itemId);
setJustClaimedNote(claimNote);
setClaimingItemId(null);
setClaimNote('');
setClaimQty(1);
fetchWishlist();
} catch (err: any) {
setClaimError(err.message || 'Erro ao reservar item');
@@ -76,27 +105,25 @@ export default function PublicWishlistPage() {
}
};
const handleUnclaim = async (itemId: string) => {
if (!confirm('Tem certeza que deseja cancelar a reserva deste item?')) {
return;
}
const handleUnclaim = async (itemId: string, guestId?: string) => {
if (!confirm('Cancelar a reserva?')) return;
setIsUnclaiming(true);
setUnclaimError('');
try {
await claimingApi.unclaim(itemId);
await claimingApi.unclaim(itemId, guestId ? { guestId } : {});
fetchWishlist();
} catch (err: any) {
setUnclaimError(err.message || 'Erro ao cancelar reserva');
alert(err.message || 'Erro ao cancelar reserva');
} finally {
setIsUnclaiming(false);
}
};
const filteredItems = showClaimed
? items
: items.filter((item) => !item.claimedAt || item.id === justClaimedItemId);
const filteredItems = items.filter((item) => {
const my = myClaimFor(item);
return item.remainingQuantity > 0 || my || item.id === justClaimedItemId;
});
const formatPrice = (price: number | null, currency: string) => {
if (!price) return null;
@@ -126,14 +153,13 @@ export default function PublicWishlistPage() {
}
return (
<PasswordLockGuard>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title={wishlist.name}
subtitle={wishlist.description || undefined}
imageUrl={wishlist.imageUrl || undefined}
maxWidth="max-w-5xl"
/>
<div className="min-h-screen bg-cosmic">
<Header
title={wishlist.name}
subtitle={wishlist.description || undefined}
imageUrl={wishlist.imageUrl || undefined}
maxWidth="max-w-5xl"
/>
{/* Main Content */}
<div className="max-w-5xl mx-auto py-12 sm:px-6 lg:px-8">
@@ -168,7 +194,6 @@ export default function PublicWishlistPage() {
className="prose prose-indigo dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 [&_a]:text-indigo-600 [&_a]:dark:text-indigo-400 [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(wishlist.preferences) }}
onClick={(e) => {
// Make all links open in new tab
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
e.preventDefault();
@@ -180,18 +205,7 @@ export default function PublicWishlistPage() {
)}
{/* Controls */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={showClaimed}
onChange={(e) => setShowClaimed(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">Mostrar itens reservados</span>
</label>
</div>
<div className="mb-6 flex items-center justify-end">
<div className="text-sm text-gray-600 dark:text-gray-400">
{filteredItems.length} de {items.length} itens
</div>
@@ -201,165 +215,184 @@ export default function PublicWishlistPage() {
{filteredItems.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
<p className="text-gray-500 dark:text-gray-400">
{showClaimed ? 'Nenhum item nesta lista ainda' : 'Todos os itens já foram reservados!'}
Todos os itens foram reservados!
</p>
</div>
) : (
<div className="space-y-6">
{filteredItems.map((item) => (
<div
key={item.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 hover:scale-105 overflow-hidden"
>
<div className="flex flex-col md:flex-row">
{/* Left: Image */}
{item.imageUrl && (
<div className="md:w-48 md:flex-shrink-0">
<img
src={item.imageUrl}
alt={item.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
)}
{filteredItems.map((item) => {
const myClaim = myClaimFor(item);
const maxForMe = item.remainingQuantity + (myClaim?.quantity ?? 0);
const showQuantitySummary = item.quantity > 1;
const sold = item.remainingQuantity === 0 && !myClaim;
{/* Middle: Item Details */}
<div className="flex-1 p-6">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{item.name}
</h3>
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
{item.description}
</p>
return (
<div
key={item.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 overflow-hidden"
>
<div className="flex flex-col md:flex-row">
{/* Left: Image */}
{item.imageUrl && (
<div className="md:w-48 md:flex-shrink-0">
<img
src={item.imageUrl}
alt={item.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
)}
</div>
{/* Right: Action Area */}
<div className="md:w-80 md:flex-shrink-0 p-6 bg-gray-50 dark:bg-gray-900/50 border-t md:border-t-0 md:border-l border-gray-200 dark:border-gray-700 flex flex-col">
<div className="mb-4">
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
<div className="space-y-2">
{item.purchaseUrls.map((url, idx) => (
<a
key={idx}
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between text-base px-4 py-3 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
>
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
{url.label}
</span>
<span className="text-gray-900 dark:text-white font-bold text-lg">
{item.price && formatPrice(item.price, item.currency)}
</span>
</a>
))}
</div>
{/* Middle: Item Details */}
<div className="flex-1 p-6">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
{item.name}
</h3>
{showQuantitySummary && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
{item.claimedQuantity} de {item.quantity} reservados
</p>
)}
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
{item.description}
</p>
)}
{/* Existing claims list */}
{item.claims.length > 0 && (
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 mt-4 border-t border-gray-200 dark:border-gray-700 pt-3">
{item.claims.map((c) => {
const isMine = c.guest.id === currentGuestId;
const canCancel = isMine || isAdmin;
return (
<li key={c.id} className="flex items-center justify-between gap-2">
<div>
<span className="font-medium">{isMine ? 'Você' : 'Reservado'}</span>
<span className="text-gray-500"> · {c.quantity} un.</span>
{c.note && isMine && (
<span className="block text-xs italic text-gray-500">
&quot;{c.note}&quot;
</span>
)}
</div>
{canCancel && (
<button
onClick={() => handleUnclaim(item.id, isMine ? undefined : c.guest.id)}
disabled={isUnclaiming}
className="text-xs text-red-600 hover:underline disabled:opacity-50 cursor-pointer"
>
Cancelar
</button>
)}
</li>
);
})}
</ul>
)}
</div>
{/* Claimed Badge, Success Message, or Claim Button/Form */}
<div className="mt-auto">
{justClaimedItemId === item.id ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center justify-center mb-2">
<div className="w-12 h-12 bg-green-500 dark:bg-green-600 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{/* Right: Action Area */}
<div className="md:w-80 md:flex-shrink-0 p-6 bg-gray-50 dark:bg-gray-900/50 border-t md:border-t-0 md:border-l border-gray-200 dark:border-gray-700 flex flex-col">
<div className="mb-4">
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
<div className="space-y-2">
{item.purchaseUrls.map((url, idx) => (
<a
key={idx}
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between text-base px-4 py-3 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
>
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
{url.label}
</span>
<span className="text-gray-900 dark:text-white font-bold text-lg">
{item.price && formatPrice(item.price, item.currency)}
</span>
</a>
))}
</div>
</div>
<p className="text-center text-lg font-semibold text-gray-900 dark:text-white mb-1">
Item reservado!
</p>
<p className="text-center text-sm text-gray-600 dark:text-gray-400 mb-2">
O status está confirmado.
</p>
{justClaimedNote && (
<p className="text-center text-xs text-gray-600 dark:text-gray-400 italic">
Sua nota: &quot;{justClaimedNote}&quot;
</p>
)}
</div>
) : item.claimedAt ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded p-3">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Reservado por {item.claimedByName}
</p>
{item.claimedByNote && (
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
Nota: {item.claimedByNote}
</p>
)}
{item.isPurchased && (
<p className="text-xs text-green-700 dark:text-green-300 mt-1 font-medium">
Comprado
</p>
)}
{showClaimed && (
<button
onClick={() => handleUnclaim(item.id)}
disabled={isUnclaiming}
className="mt-3 w-full px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 font-medium disabled:opacity-50 transition-colors cursor-pointer text-sm"
>
{isUnclaiming ? 'Cancelando...' : 'Cancelar reserva'}
</button>
)}
</div>
) : claimingItemId === item.id ? (
<div className="space-y-3">
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
{claimError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
{claimError}
<div className="mt-auto">
{sold ? (
<div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300">
Esgotado
</div>
) : claimingItemId === item.id ? (
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
{claimError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
{claimError}
</div>
)}
{item.quantity > 1 && (
<div>
<label htmlFor={`claim-qty-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Quantidade (máx {maxForMe}):
</label>
<input
id={`claim-qty-${item.id}`}
type="number"
min={1}
max={maxForMe}
value={claimQty}
onChange={(e) => setClaimQty(Math.max(1, Math.min(maxForMe, Number(e.target.value) || 1)))}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white"
/>
</div>
)}
<div>
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Deixe uma nota (opcional):
</label>
<textarea
id={`claim-note-${item.id}`}
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white resize-none"
value={claimNote}
onChange={(e) => setClaimNote(e.target.value)}
/>
</div>
)}
<div>
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Deixe uma nota (opcional):
</label>
<textarea
id={`claim-note-${item.id}`}
rows={3}
placeholder="Ex: 'Vou comprar na semana que vem' ou 'Achei uma boa promoção'"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white resize-none"
value={claimNote}
onChange={(e) => setClaimNote(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isClaiming}
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
>
{isClaiming ? 'Reservando...' : (myClaim ? 'Atualizar reserva' : 'Confirmar reserva')}
</button>
<button
type="button"
onClick={() => setClaimingItemId(null)}
className="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancelar
</button>
</form>
) : (
<button
type="submit"
disabled={isClaiming}
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
onClick={() => handleStartClaim(item)}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
>
{isClaiming ? 'Reservando...' : 'Confirmar reserva'}
{myClaim ? 'Atualizar reserva' : 'Reservar'}
</button>
</form>
)}
</div>
) : (
<button
onClick={() => handleClaimItem(item.id)}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
>
Vou dar este presente
</button>
)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>
</div>
<Footer />
</div>
</PasswordLockGuard>
</div>
);
}

96
app/admin/guests/page.tsx Normal file
View File

@@ -0,0 +1,96 @@
'use client';
import { useEffect, useState } from 'react';
import AdminGuard from '@/components/admin-guard';
import { guestsApi, type Guest } from '@/lib/api';
export default function AdminGuestsPage() {
return (
<AdminGuard>
<GuestsManager />
</AdminGuard>
);
}
function GuestsManager() {
const [guests, setGuests] = useState<Guest[]>([]);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const load = async () => setGuests(await guestsApi.list());
useEffect(() => { load(); }, []);
const linkFor = (g: Guest) => {
const base = typeof window === 'undefined' ? '' : window.location.origin;
return `${base}/?usr=${g.id}`;
};
const onCreate = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setBusy(true);
try {
await guestsApi.create(name.trim());
setName('');
await load();
} catch (e: any) {
setErr(e.message || 'Erro ao criar');
} finally {
setBusy(false);
}
};
const onRename = async (g: Guest) => {
const next = prompt('Novo nome', g.name);
if (!next || next.trim() === g.name) return;
await guestsApi.rename(g.id, next.trim());
await load();
};
const onDelete = async (g: Guest) => {
if (!confirm(`Excluir o convidado ${g.name}? As reservas dele(a) ficarão sem dono.`)) return;
await guestsApi.delete(g.id);
await load();
};
const onCopy = async (g: Guest) => {
await navigator.clipboard.writeText(linkFor(g));
};
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Convidados</h1>
<form onSubmit={onCreate} className="flex gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nome do convidado (opcional)"
className="flex-1 border rounded px-3 py-2"
/>
<button disabled={busy} className="bg-indigo-600 text-white rounded px-4 py-2 disabled:opacity-50">
{busy ? 'Criando…' : 'Criar link'}
</button>
</form>
{err && <div className="text-red-600 text-sm">{err}</div>}
<ul className="divide-y border rounded">
{guests.map((g) => (
<li key={g.id} className="p-3 flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{g.name}</div>
<div className="text-xs text-gray-500 truncate">{linkFor(g)}</div>
</div>
<div className="flex gap-2 shrink-0">
<button onClick={() => onCopy(g)} className="text-sm border rounded px-2 py-1">Copiar link</button>
<button onClick={() => onRename(g)} className="text-sm border rounded px-2 py-1">Renomear</button>
<button onClick={() => onDelete(g)} className="text-sm border rounded px-2 py-1 text-red-600">Excluir</button>
</div>
</li>
))}
{guests.length === 0 && <li className="p-3 text-gray-500 text-sm">Nenhum convidado ainda</li>}
</ul>
</div>
);
}

View File

@@ -1,140 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import type { ApiError } from '@/lib/api';
export default function AdminLoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
// Redirect if already logged in
useEffect(() => {
if (!authLoading && isAuthenticated) {
router.push('/admin');
}
}, [isAuthenticated, authLoading, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(username, password);
router.push('/admin');
} catch (err) {
const apiError = err as ApiError;
setError(apiError.message || 'Login failed');
} finally {
setIsLoading(false);
}
};
// Show loading while checking auth status
if (authLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
// Don't render login form if already authenticated (will redirect)
if (isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Hero Section */}
<div className="bg-white dark:bg-gray-800 shadow-sm">
<div className="max-w-7xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
Admin Login
</h1>
<p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-6">
Sign in to your account
</p>
<Link
href="/"
className="inline-flex items-center text-base font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 transition-colors"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Home
</Link>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-4xl mx-auto py-12 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
<div className="max-w-md mx-auto">
<form className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-100 dark:border-gray-700 p-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-base"
placeholder="admin"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-base"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg transition-all"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import ProtectedRoute from '@/components/protected-route';
import { useAuth } from '@/lib/auth-context';
import { wishlistsApi, itemsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
import { useRouter } from 'next/navigation';
import AdminGuard from '@/components/admin-guard';
import { authApi, wishlistsApi, itemsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
import Header from '@/components/header';
import Footer from '@/components/footer';
import Link from 'next/link';
import StatsGrid from '@/components/admin/StatsGrid';
import SettingsSection from '@/components/admin/SettingsSection';
@@ -14,15 +13,27 @@ import CreateWishlistModal from '@/components/admin/CreateWishlistModal';
import ShareButton from '@/components/share-button';
export default function AdminPage() {
const { logout } = useAuth();
return (
<AdminGuard>
<AdminPageContent />
</AdminGuard>
);
}
function AdminPageContent() {
const router = useRouter();
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
const [itemCounts, setItemCounts] = useState<Record<string, number>>({});
const [isLoading, setIsLoading] = useState(true);
const [settings, setSettings] = useState<Settings>({
siteTitle: 'Wishlist',
homepageSubtext: 'Browse and explore available wishlists',
passwordLockEnabled: false,
});
const logout = async () => {
await authApi.logout();
router.push('/');
};
const [showCreateModal, setShowCreateModal] = useState(false);
const [createError, setCreateError] = useState('');
@@ -125,7 +136,7 @@ export default function AdminPage() {
};
return (
<ProtectedRoute>
<>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title="Dashboard"
@@ -143,6 +154,12 @@ export default function AdminPage() {
>
View Public Site
</Link>
<Link
href="/admin/guests"
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
>
Convidados
</Link>
<button
onClick={logout}
className="inline-flex items-center px-6 py-3 border-2 border-red-600 dark:border-red-500 text-base font-semibold rounded-lg text-red-600 dark:text-red-400 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all cursor-pointer"
@@ -230,9 +247,7 @@ export default function AdminPage() {
onCreate={handleCreateWishlist}
error={createError}
/>
<Footer />
</div>
</ProtectedRoute>
</>
);
}

View File

@@ -1,12 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
const { slug } = await params;
const wishlist = await db
@@ -22,8 +29,8 @@ export async function GET(
);
}
// Only return public wishlists
if (!wishlist[0].isPublic) {
// Only return public wishlists (admin can see all)
if (!wishlist[0].isPublic && !isAdmin) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, guests } from '@/lib/db';
import { verifyAdminToken } from '@/lib/auth/tokens';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const name = (body?.name ?? '').toString().trim();
if (!name) {
return NextResponse.json({ error: 'name is required' }, { status: 400 });
}
const [row] = await db
.update(guests)
.set({ name, updatedAt: new Date() })
.where(eq(guests.id, id))
.returning();
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ success: true, guest: row });
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const result = await db.delete(guests).where(eq(guests.id, id)).returning();
if (result.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { desc } from 'drizzle-orm';
import { db, guests } from '@/lib/db';
import { verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(request: NextRequest) {
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const rows = await db.select().from(guests).orderBy(desc(guests.createdAt));
return NextResponse.json({ success: true, guests: rows });
}
export async function POST(request: NextRequest) {
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const name = (body?.name ?? '').toString().trim() || 'Convidado';
const [row] = await db.insert(guests).values({ name }).returning();
return NextResponse.json({ success: true, guest: row }, { status: 201 });
}

View File

@@ -1,62 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, validateAdminCredentials, isSecureCookie } from '@/lib/auth/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
// Validate credentials
if (!validateAdminCredentials(username, password)) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Generate tokens
const accessToken = generateAccessToken(username);
const refreshToken = generateRefreshToken(username);
// Create response
const response = NextResponse.json({
success: true,
user: { username },
accessToken,
refreshToken,
});
// Set cookies
const cookieOptions = {
httpOnly: true,
secure: isSecureCookie(request),
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', accessToken, {
...cookieOptions,
maxAge: 72 * 60 * 60, // 72 hours
});
response.cookies.set('refresh_token', refreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { ADM_COOKIE, USR_COOKIE, buildClearCookie, isSecureCookie } from '@/lib/auth/cookies';
export async function POST(request: NextRequest) {
const response = NextResponse.json({
success: true,
message: 'Logged out successfully'
});
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return response;
const secure = isSecureCookie(request);
const headers = new Headers();
headers.append('Set-Cookie', buildClearCookie(ADM_COOKIE, secure));
headers.append('Set-Cookie', buildClearCookie(USR_COOKIE, secure));
return NextResponse.json({ success: true }, { headers });
}

View File

@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
user: { username: payload.username },
});
} catch (error) {
console.error('Auth error:', error);
return NextResponse.json(
{ error: 'Authentication failed' },
{ status: 500 }
);
}
}

View File

@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken, isSecureCookie } from '@/lib/auth/utils';
export async function POST(request: NextRequest) {
try {
// Get refresh token from cookie or body
let refreshToken: string | undefined;
const cookieToken = request.cookies.get('refresh_token')?.value;
if (cookieToken) {
refreshToken = cookieToken;
} else {
try {
const body = await request.json();
refreshToken = body.refreshToken;
} catch {
// No body or invalid JSON, continue without it
refreshToken = undefined;
}
}
if (!refreshToken) {
return NextResponse.json(
{ error: 'No refresh token provided' },
{ status: 401 }
);
}
// Verify refresh token
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired refresh token' },
{ status: 401 }
);
}
// Generate new tokens
const newAccessToken = generateAccessToken(payload.username);
const newRefreshToken = generateRefreshToken(payload.username);
// Create response
const response = NextResponse.json({
success: true,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
// Set new cookies
const cookieOptions = {
httpOnly: true,
secure: isSecureCookie(request),
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', newAccessToken, {
...cookieOptions,
maxAge: 72 * 60 * 60, // 72 hours
});
response.cookies.set('refresh_token', newRefreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error('Refresh error:', error);
return NextResponse.json(
{ error: 'Token refresh failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAdminTokenValue, isGuestTokenValue, getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
import { ADM_COOKIE, USR_COOKIE, buildCookie, isSecureCookie } from '@/lib/auth/cookies';
const ONE_YEAR = 60 * 60 * 24 * 365;
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const { adm, usr } = body as { adm?: string; usr?: string };
const headers = new Headers();
const secure = isSecureCookie(request);
const cookies: string[] = [];
let role: 'admin' | 'guest' | 'none' = 'none';
let guestId: string | null = null;
if (adm) {
if (!isAdminTokenValue(adm)) {
return NextResponse.json({ error: 'Invalid admin token' }, { status: 401 });
}
cookies.push(buildCookie(ADM_COOKIE, adm, secure, ONE_YEAR));
role = 'admin';
}
if (usr) {
const guest = await isGuestTokenValue(usr);
if (!guest) {
return NextResponse.json({ error: 'Invalid guest token' }, { status: 401 });
}
cookies.push(buildCookie(USR_COOKIE, usr, secure, ONE_YEAR));
if (role !== 'admin') role = 'guest';
guestId = guest.id;
}
if (cookies.length === 0) {
return NextResponse.json({ error: 'No token provided' }, { status: 400 });
}
for (const c of cookies) headers.append('Set-Cookie', c);
return NextResponse.json({ success: true, role, guestId }, { headers });
}
export async function GET(request: NextRequest) {
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (isAdmin) return NextResponse.json({ role: 'admin', guest: guest ? { id: guest.id, name: guest.name } : null });
if (guest) return NextResponse.json({ role: 'guest', guest: { id: guest.id, name: guest.name } });
return NextResponse.json({ role: 'none' });
}

View File

@@ -1,28 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken } from '@/lib/auth/tokens';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken, getGuestFromRequest } from '@/lib/auth/tokens';
import { attachClaimsToItems } from '@/lib/items-with-claims';
export async function GET(
request: NextRequest,
@@ -10,10 +11,11 @@ export async function GET(
try {
const { id } = await params;
// Check for auth token
const token = request.cookies.get('access_token')?.value;
const payload = token ? verifyAccessToken(token) : null;
const isAuthenticated = payload !== null;
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Get item
const item = await db
@@ -43,17 +45,18 @@ export async function GET(
);
}
// Check permissions
if (!wishlist[0].isPublic && !isAuthenticated) {
if (!wishlist[0].isPublic && !isAdmin) {
return NextResponse.json(
{ error: 'This item is private' },
{ status: 403 }
);
}
const [withClaims] = await attachClaimsToItems(item);
return NextResponse.json({
success: true,
item: item[0],
item: withClaims,
});
} catch (error) {
console.error('Error fetching item:', error);
@@ -69,21 +72,8 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
@@ -114,7 +104,7 @@ export async function PATCH(
}
// Build update object (only include provided fields)
const updateData: any = {
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
};
@@ -152,21 +142,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;

View File

@@ -1,67 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db';
import crypto from 'crypto';
import { isSecureCookie } from '@/lib/auth/utils';
// POST /api/lock - Verify password
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { password } = body;
if (!password) {
return NextResponse.json(
{ error: 'Password is required' },
{ status: 400 }
);
}
// Get the stored password hash
const hashSetting = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockHash'))
.limit(1);
if (hashSetting.length === 0) {
return NextResponse.json(
{ error: 'Password lock not configured' },
{ status: 400 }
);
}
// Hash the provided password
const hash = crypto.createHash('sha256').update(password).digest('hex');
// Compare hashes
if (hash === hashSetting[0].value) {
// Password correct - set a cookie
const response = NextResponse.json({
success: true,
message: 'Password verified',
});
response.cookies.set('site_unlocked', 'true', {
httpOnly: true,
secure: isSecureCookie(request),
sameSite: 'lax',
maxAge: 60 * 60 * 24,
path: '/',
});
return response;
} else {
return NextResponse.json(
{ error: 'Incorrect password' },
{ status: 401 }
);
}
} catch (error) {
console.error('Error verifying password:', error);
return NextResponse.json(
{ error: 'Failed to verify password' },
{ status: 500 }
);
}
}

View File

@@ -1,89 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { createId } from '@paralleldrive/cuid2';
import { and, eq, ne, sql } from 'drizzle-orm';
import { db, wishlistItems, wishlists, itemClaims, guests } from '@/lib/db';
import { getGuestFromRequest } from '@/lib/auth/tokens';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { name, note } = body;
// Get the item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
const guest = await getGuestFromRequest(request);
if (!guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Check if item is already claimed
if (item[0].claimedByToken) {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const requestedQty = Math.max(1, Math.floor(Number(body?.quantity ?? 1)));
const note = (body?.note ?? '').toString().trim() || null;
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1);
if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 });
const item = itemRows[0];
const wl = await db.select().from(wishlists).where(eq(wishlists.id, item.wishlistId)).limit(1);
if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 });
if (!wl[0].isPublic) return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 });
// Sum quantities reserved by OTHER guests
const otherSumRow = await db
.select({ total: sql<number>`COALESCE(SUM(${itemClaims.quantity}), 0)` })
.from(itemClaims)
.where(and(eq(itemClaims.itemId, id), ne(itemClaims.guestId, guest.id)));
const otherSum = Number(otherSumRow[0]?.total ?? 0);
if (otherSum + requestedQty > item.quantity) {
const remaining = Math.max(0, item.quantity - otherSum);
return NextResponse.json(
{ error: 'Item is already claimed' },
{ error: `Apenas ${remaining} disponível(is)`, remaining },
{ status: 409 }
);
}
// Check if wishlist is public
const wishlist = await db
// Upsert: replace existing claim for this (item, guest)
const existing = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.from(itemClaims)
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
if (existing.length > 0) {
await db
.update(itemClaims)
.set({ quantity: requestedQty, note, updatedAt: new Date() })
.where(eq(itemClaims.id, existing[0].id));
} else {
await db.insert(itemClaims).values({
itemId: id,
guestId: guest.id,
quantity: requestedQty,
note,
});
}
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
// Generate unique claim token
const claimToken = createId();
// Update item with claim information
const updatedItem = await db
.update(wishlistItems)
.set({
claimedByName: name || null,
claimedByNote: note || null,
claimedByToken: claimToken,
claimedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json(
{
success: true,
claimToken,
item: updatedItem[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error claiming item:', error);
return NextResponse.json(
{ error: 'Failed to claim item' },
{ status: 500 }
);
const updated = await fetchItemWithClaims(id);
return NextResponse.json({ success: true, item: updated }, { status: 201 });
} catch (err) {
console.error('Error claiming item:', err);
return NextResponse.json({ error: 'Failed to claim item' }, { status: 500 });
}
}
async function fetchItemWithClaims(itemId: string) {
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, itemId)).limit(1);
if (itemRows.length === 0) return null;
const claims = await db
.select({
id: itemClaims.id,
quantity: itemClaims.quantity,
note: itemClaims.note,
isPurchased: itemClaims.isPurchased,
claimedAt: itemClaims.claimedAt,
guestId: guests.id,
guestName: guests.name,
})
.from(itemClaims)
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
.where(eq(itemClaims.itemId, itemId));
const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0);
return {
...itemRows[0],
claims: claims.map((c) => ({
id: c.id,
quantity: c.quantity,
note: c.note,
isPurchased: c.isPurchased,
claimedAt: c.claimedAt,
guest: { id: c.guestId, name: c.guestName },
})),
claimedQuantity,
remainingQuantity: Math.max(0, itemRows[0].quantity - claimedQuantity),
};
}

View File

@@ -1,84 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { and, eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists, itemClaims } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const guest = await getGuestFromRequest(request);
const isAdmin = verifyAdminToken(request);
if (!guest && !isAdmin) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
// Get the item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
const targetGuestId = isAdmin && typeof body?.guestId === 'string' ? body.guestId : guest?.id;
if (!targetGuestId) {
return NextResponse.json({ error: 'Missing guest' }, { status: 400 });
}
// Check if item is actually claimed
if (!item[0].claimedByToken) {
return NextResponse.json(
{ error: 'Item is not claimed' },
{ status: 400 }
);
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1);
if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 });
const wl = await db.select().from(wishlists).where(eq(wishlists.id, itemRows[0].wishlistId)).limit(1);
if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 });
if (!wl[0].isPublic && !isAdmin) {
return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 });
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Remove claim information (honor system - no verification)
const updatedItem = await db
.update(wishlistItems)
.set({
claimedByName: null,
claimedByNote: null,
claimedByToken: null,
claimedAt: null,
isPurchased: false,
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
const result = await db
.delete(itemClaims)
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, targetGuestId)))
.returning();
return NextResponse.json(
{
success: true,
message: 'Item unclaimed successfully',
item: updatedItem[0],
},
{ status: 200 }
);
} catch (error) {
console.error('Error unclaiming item:', error);
return NextResponse.json(
{ error: 'Failed to unclaim item' },
{ status: 500 }
);
if (result.length === 0) {
return NextResponse.json({ error: 'Reserva não encontrada' }, { status: 404 });
}
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
return NextResponse.json({ success: true });
} catch (err) {
console.error('Error unclaiming item:', err);
return NextResponse.json({ error: 'Failed to unclaim item' }, { status: 500 });
}
}

View File

@@ -1,9 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(request: NextRequest) {
try {
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Fetch only public wishlists
const publicWishlists = await db
.select()

View File

@@ -1,24 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken } from '@/lib/auth/tokens';
import { scrapeUrl } from '@/lib/scraping/service';
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();

View File

@@ -1,21 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import crypto from 'crypto';
import { verifyAdminToken } from '@/lib/auth/tokens';
// GET /api/settings - Get all settings (public endpoint for reading only)
export async function GET(request: NextRequest) {
export async function GET() {
try {
const allSettings = await db.select().from(settings);
// Convert to key-value object
const settingsObj = allSettings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string | boolean>);
}, {} as Record<string, string>);
// Set defaults if not found
if (!settingsObj.siteTitle) {
settingsObj.siteTitle = 'Wishlist';
}
@@ -23,12 +20,12 @@ export async function GET(request: NextRequest) {
settingsObj.homepageSubtext = 'Browse and explore available wishlists';
}
// Convert passwordLockEnabled to boolean
(settingsObj as any).passwordLockEnabled = settingsObj.passwordLockEnabled === 'true';
return NextResponse.json({
success: true,
settings: settingsObj,
settings: {
siteTitle: settingsObj.siteTitle,
homepageSubtext: settingsObj.homepageSubtext,
},
});
} catch (error) {
console.error('Error fetching settings:', error);
@@ -42,27 +39,13 @@ export async function GET(request: NextRequest) {
// PUT /api/settings - Update settings (admin only)
export async function PUT(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { siteTitle, homepageSubtext, passwordLockEnabled, passwordLock } = body;
const { siteTitle, homepageSubtext } = body;
// Update or insert siteTitle
if (siteTitle !== undefined) {
const existing = await db
.select()
@@ -83,7 +66,6 @@ export async function PUT(request: NextRequest) {
}
}
// Update or insert homepageSubtext
if (homepageSubtext !== undefined) {
const existing = await db
.select()
@@ -104,52 +86,6 @@ export async function PUT(request: NextRequest) {
}
}
// Update or insert passwordLockEnabled
if (passwordLockEnabled !== undefined) {
const value = passwordLockEnabled ? 'true' : 'false';
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockEnabled'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value, updatedAt: new Date() })
.where(eq(settings.key, 'passwordLockEnabled'));
} else {
await db.insert(settings).values({
key: 'passwordLockEnabled',
value,
});
}
}
// Update password hash if provided
if (passwordLock && passwordLock.trim() !== '') {
// Hash the password using SHA-256
const hash = crypto.createHash('sha256').update(passwordLock).digest('hex');
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockHash'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: hash, updatedAt: new Date() })
.where(eq(settings.key, 'passwordLockHash'));
} else {
await db.insert(settings).values({
key: 'passwordLockHash',
value: hash,
});
}
}
return NextResponse.json({
success: true,
message: 'Settings updated successfully',

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, and, desc } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken, getGuestFromRequest } from '@/lib/auth/tokens';
import { attachClaimsToItems } from '@/lib/items-with-claims';
export async function GET(
request: NextRequest,
@@ -10,10 +11,11 @@ export async function GET(
try {
const { id } = await params;
// Check for auth token
const token = request.cookies.get('access_token')?.value;
const payload = token ? verifyAccessToken(token) : null;
const isAuthenticated = payload !== null;
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Check if wishlist exists
const wishlist = await db
@@ -29,20 +31,20 @@ export async function GET(
);
}
// Check permissions
if (!wishlist[0].isPublic && !isAuthenticated) {
// Permissions: guest can only see public wishlists; admin sees all
if (!wishlist[0].isPublic && !isAdmin) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Get all items (exclude archived unless authenticated)
const items = await db
// Get all items (exclude archived unless admin)
const raw = await db
.select()
.from(wishlistItems)
.where(
isAuthenticated
isAdmin
? eq(wishlistItems.wishlistId, id)
: and(
eq(wishlistItems.wishlistId, id),
@@ -51,12 +53,11 @@ export async function GET(
)
.orderBy(wishlistItems.sortOrder);
// Return items
const responseItems = items;
const items = await attachClaimsToItems(raw);
return NextResponse.json({
success: true,
items: responseItems,
items,
});
} catch (error) {
console.error('Error fetching items:', error);
@@ -72,21 +73,8 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;

View File

@@ -1,28 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken } from '@/lib/auth/tokens';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;

View File

@@ -1,28 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
@@ -58,21 +45,8 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
@@ -145,21 +119,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;

View File

@@ -1,25 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import { verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const allWishlists = await db
@@ -42,21 +29,8 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();

View File

@@ -1,20 +1,29 @@
@import "tailwindcss";
@variant dark (.dark &);
/* Mars palette — warm rust, terracotta, dusty sand, deep crater shadows */
:root {
--background: #ffffff;
--foreground: #171717;
--background: #fff5ee; /* warm sand mist */
--foreground: #3a1f15;
--ink: #3a1f15; /* deep crater */
--ink-soft: #6b3a26; /* baked clay */
--muted: #a07560;
--card: #ffffff;
--card-soft: #fdebdc; /* dust film */
--border: #f3d8c3;
--accent: #c1502c; /* mars-rust */
--accent-soft: #fde0d0;
--success: #7a8c4d; /* olive — softer than green on warm bg */
--success-soft: #f0eedb;
--success-border: #d6d4a4;
--success-ink: #4f5a30;
--halo-1: rgba(231, 111, 65, 0.45); /* burnt orange */
--halo-2: rgba(255, 188, 138, 0.50); /* peach dust */
--halo-3: rgba(196, 76, 39, 0.32); /* deep rust */
--planet-core: #c1502c;
--planet-rim: #8a2f15;
color-scheme: light;
}
:root[data-theme="dark"],
.dark {
--background: #0a0a0a;
--foreground: #ededed;
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -25,38 +34,77 @@
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: 'Quicksand', 'Nunito', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Light mode form elements */
/*
* Mars-scape background:
* - warm dusty gradient base
* - distant orange/peach halos (atmospheric haze)
* - sparse pinpoint stars (deep space)
*/
.bg-cosmic {
background-color: var(--background);
background-image:
radial-gradient(circle at 12% 18%, var(--halo-1) 0%, transparent 38%),
radial-gradient(circle at 88% 14%, var(--halo-2) 0%, transparent 44%),
radial-gradient(circle at 50% 92%, var(--halo-3) 0%, transparent 50%),
radial-gradient(1px 1px at 22% 30%, rgba(255, 235, 215, 0.8) 50%, transparent 51%),
radial-gradient(1px 1px at 70% 22%, rgba(255, 235, 215, 0.7) 50%, transparent 51%),
radial-gradient(1px 1px at 40% 70%, rgba(255, 235, 215, 0.7) 50%, transparent 51%),
radial-gradient(1px 1px at 82% 60%, rgba(255, 235, 215, 0.8) 50%, transparent 51%),
radial-gradient(1px 1px at 18% 82%, rgba(255, 235, 215, 0.7) 50%, transparent 51%),
linear-gradient(180deg, var(--background) 0%, color-mix(in oklab, var(--background), var(--halo-2) 18%) 100%);
background-attachment: fixed;
}
/*
* Mars-disk glyph: a soft layered radial gradient that reads as a planet.
* Used as a decorative ::before in the header.
*/
.mars-disk {
position: absolute;
border-radius: 9999px;
background:
radial-gradient(circle at 35% 32%, color-mix(in oklab, var(--planet-core), white 25%) 0%, var(--planet-core) 38%, var(--planet-rim) 78%, transparent 100%),
radial-gradient(circle at 70% 70%, rgba(0,0,0,0.18), transparent 60%);
box-shadow:
0 0 60px 10px color-mix(in oklab, var(--planet-core), transparent 70%),
inset -8px -8px 30px color-mix(in oklab, var(--planet-rim), transparent 50%);
opacity: 0.85;
}
.bg-card {
background-color: var(--card);
}
.bg-card-soft {
background-color: var(--card-soft);
}
.shadow-soft {
box-shadow:
0 6px 22px -10px color-mix(in oklab, var(--accent), transparent 70%),
0 2px 6px -2px rgba(58, 31, 21, 0.10);
}
.shadow-lifted {
box-shadow:
0 22px 44px -22px color-mix(in oklab, var(--accent), transparent 55%),
0 8px 16px -8px rgba(58, 31, 21, 0.12);
}
/* Form elements */
:root input,
:root textarea,
:root select {
color: #171717;
background-color: #ffffff;
color: var(--ink);
background-color: var(--card);
border-color: var(--border);
}
:root input::placeholder,
:root textarea::placeholder {
color: #9ca3af;
}
/* Dark mode form elements */
:root[data-theme="dark"] input,
:root[data-theme="dark"] textarea,
:root[data-theme="dark"] select,
.dark input,
.dark textarea,
.dark select {
color: #ededed;
background-color: #1f2937;
border-color: #374151;
}
:root[data-theme="dark"] input::placeholder,
:root[data-theme="dark"] textarea::placeholder,
.dark input::placeholder,
.dark textarea::placeholder {
color: #6b7280;
color: var(--muted);
}

View File

@@ -1,9 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/lib/auth-context";
import { ThemeProvider } from "@/components/theme-provider";
import { db, settings } from "@/lib/db";
import { eq } from "drizzle-orm";
async function getSettings() {
try {
@@ -14,12 +11,12 @@ async function getSettings() {
}, {} as Record<string, string>);
return {
siteTitle: settingsObj.siteTitle || 'Chá de Bebê',
siteTitle: settingsObj.siteTitle || 'Chá do Martin',
homepageSubtext: settingsObj.homepageSubtext || 'Escolha um presente da lista!',
};
} catch (error) {
return {
siteTitle: 'Chá de Bebê',
siteTitle: 'Chá do Martin',
homepageSubtext: 'Escolha um presente da lista!',
};
}
@@ -43,10 +40,16 @@ export default function RootLayout({
}>) {
return (
<html lang="pt-BR">
<body className="font-sans antialiased bg-gray-50 dark:bg-gray-900">
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="font-sans antialiased bg-cosmic">
{children}
</body>
</html>
);

View File

@@ -1,95 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Header from '@/components/header';
import Footer from '@/components/footer';
export default function LockPage() {
const router = useRouter();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const response = await fetch('/api/lock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (response.ok) {
// Password verified, redirect to home
router.push('/');
router.refresh();
} else {
setError(data.error || 'Senha incorreta');
setPassword('');
}
} catch (err) {
setError('Erro ao verificar senha. Tente novamente.');
console.error('Lock verification error:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title="Senha necessária"
subtitle="Digite a senha para acessar o site"
/>
<div className="max-w-md mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Senha
</label>
<input
type="password"
id="password"
required
autoFocus
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-lg"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Digite a senha"
disabled={isSubmitting}
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-6 py-3 text-lg font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Verificando...' : 'Entrar'}
</button>
</form>
</div>
</div>
</div>
<Footer />
</div>
);
}

View File

@@ -2,106 +2,93 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { wishlistsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
import Header from '@/components/header';
import Footer from '@/components/footer';
import PasswordLockGuard from '@/components/password-lock-guard';
import ShareButton from '@/components/share-button';
import { useRouter, useSearchParams } from 'next/navigation';
import { authApi, wishlistsApi, type Wishlist } from '@/lib/api';
export default function Home() {
export default function HomePage() {
const router = useRouter();
const params = useSearchParams();
const [state, setState] = useState<'checking' | 'guest' | 'none'>('checking');
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [settings, setSettings] = useState<Settings>({ siteTitle: 'Chá de Bebê', homepageSubtext: 'Escolha um presente da lista!' });
useEffect(() => {
fetchWishlists();
fetchSettings();
}, []);
let cancelled = false;
(async () => {
const adm = params.get('adm');
const usr = params.get('usr');
try {
if (adm) {
await authApi.session({ adm });
router.replace('/admin');
return;
}
if (usr) {
await authApi.session({ usr });
}
const who = await authApi.whoami();
if (cancelled) return;
if (who.role === 'admin') {
router.replace('/admin');
return;
}
if (who.role === 'guest') {
const lists = await wishlistsApi.getAllPublic();
if (cancelled) return;
if (lists.length === 1) {
router.replace(`/${lists[0].slug}`);
return;
}
setWishlists(lists);
setState('guest');
return;
}
setState('none');
} catch {
if (!cancelled) setState('none');
}
})();
return () => { cancelled = true; };
}, [params, router]);
const fetchSettings = async () => {
try {
const data = await settingsApi.getSettings();
setSettings(data);
} catch (error) {
console.error('Failed to fetch settings:', error);
}
};
if (state === 'checking') {
return <div className="min-h-screen flex items-center justify-center text-gray-500">Carregando</div>;
}
const fetchWishlists = async () => {
try {
const data = await wishlistsApi.getAllPublic();
setWishlists(data);
// Item counts removed - requires authentication
} catch (error) {
console.error('Failed to fetch wishlists:', error);
} finally {
setIsLoading(false);
}
};
if (state === 'guest') {
return (
<div className="min-h-screen flex items-center justify-center px-6">
<div className="w-full max-w-2xl text-center">
<h1 className="text-3xl font-bold mb-6">Bem-vindo</h1>
{wishlists.length === 0 ? (
<p className="text-gray-500">Nenhuma lista disponível ainda.</p>
) : (
<ul className="space-y-3 text-left">
{wishlists.map((w) => (
<li key={w.id}>
<Link
href={`/${w.slug}`}
className="block rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:border-indigo-400 hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10 transition"
>
<div className="font-semibold text-lg">{w.name}</div>
{w.description && (
<div className="text-sm text-gray-500 mt-1">{w.description}</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
</div>
);
}
return (
<PasswordLockGuard>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title={settings.siteTitle}
subtitle={settings.homepageSubtext}
actions={
<ShareButton
title="Veja a lista do chá de bebê!"
text="Dê uma olhada na lista de presentes do chá de bebê."
/>
}
/>
{/* Main Content */}
<div className="max-w-4xl mx-auto py-12 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Carregando...</p>
</div>
) : wishlists.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
<p className="text-gray-500 dark:text-gray-400">Nenhuma lista disponível ainda</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{wishlists.map((wishlist) => (
<Link
key={wishlist.id}
href={`/${wishlist.slug}`}
className="bg-white dark:bg-gray-800 rounded-xl shadow-md hover:shadow-xl transition-all duration-300 hover:scale-105 border border-gray-100 dark:border-gray-700 overflow-hidden"
>
<div className="flex flex-col md:flex-row">
{wishlist.imageUrl && (
<div className="md:w-64 md:flex-shrink-0">
<img
src={wishlist.imageUrl}
alt={wishlist.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
)}
<div className="p-8 flex-1">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{wishlist.name}
</h3>
{wishlist.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
{wishlist.description}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
<Footer />
<div className="min-h-screen flex items-center justify-center text-center px-6">
<div>
<h1 className="text-3xl font-bold mb-2">Convite necessário</h1>
<p className="text-gray-500">Esta página é por convite. Use o link que recebeu.</p>
</div>
</PasswordLockGuard>
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { authApi } from '@/lib/api';
export default function AdminGuard({ children }: { children: React.ReactNode }) {
const params = useSearchParams();
const [state, setState] = useState<'checking' | 'ok' | 'denied'>('checking');
useEffect(() => {
let cancelled = false;
(async () => {
const adm = params.get('adm');
try {
if (adm) {
await authApi.session({ adm });
// strip the param from URL but keep route
const url = new URL(window.location.href);
url.searchParams.delete('adm');
window.history.replaceState({}, '', url.toString());
}
const who = await authApi.whoami();
if (cancelled) return;
if (who.role === 'admin') setState('ok');
else setState('denied');
} catch {
if (!cancelled) setState('denied');
}
})();
return () => { cancelled = true; };
}, [params]);
if (state === 'checking') return <div className="min-h-screen flex items-center justify-center text-gray-500">Verificando</div>;
if (state === 'denied') return <div className="min-h-screen flex items-center justify-center text-center px-6">
<div>
<h1 className="text-2xl font-bold mb-2">Acesso restrito</h1>
<p className="text-gray-500">Adicione <code>?adm=&lt;token&gt;</code> à URL para entrar.</p>
</div>
</div>;
return <>{children}</>;
}

View File

@@ -97,44 +97,6 @@ export default function SettingsSection({ settings, onUpdate }: SettingsSectionP
This appears below the title on the homepage
</p>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center mb-3">
<input
type="checkbox"
id="passwordLockEnabled"
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
checked={settingsForm.passwordLockEnabled}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, passwordLockEnabled: e.target.checked }))
}
/>
<label htmlFor="passwordLockEnabled" className="ml-2 block text-base font-medium text-gray-700 dark:text-gray-300">
Enable Password Lock
</label>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
When enabled, visitors must enter a password to access the website
</p>
{settingsForm.passwordLockEnabled && (
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Password
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.passwordLock || ''}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, passwordLock: e.target.value }))
}
placeholder="Enter password (leave blank to keep current)"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Leave blank to keep the current password unchanged
</p>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
@@ -161,20 +123,6 @@ export default function SettingsSection({ settings, onUpdate }: SettingsSectionP
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Homepage Subtext</p>
<p className="text-base text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Password Lock</p>
<p className="text-base text-gray-900 dark:text-white">
{settings.passwordLockEnabled ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
Enabled
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">
Disabled
</span>
)}
</p>
</div>
</div>
)}
</div>

View File

@@ -1,57 +0,0 @@
'use client';
import { useTheme } from '@/components/theme-provider';
export default function Footer() {
const { theme, toggleTheme } = useTheme();
return (
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col items-center gap-2 text-sm">
<div className="flex items-center gap-3">
<button
onClick={toggleTheme}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300 font-medium"
aria-label="Alternar tema"
>
{theme === 'light' ? (
<>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span>Modo escuro</span>
</>
) : (
<>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span>Modo claro</span>
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { authApi } from '@/lib/api';
type Status = 'checking' | { kind: 'ok'; guestName: string } | 'denied';
export default function GuestGuard({ children }: { children: React.ReactNode }) {
const params = useSearchParams();
const [status, setStatus] = useState<Status>('checking');
useEffect(() => {
let cancelled = false;
(async () => {
const usr = params.get('usr');
try {
if (usr) {
await authApi.session({ usr });
// user said leaving the token in URL is fine — do NOT strip
}
const who = await authApi.whoami();
if (cancelled) return;
if (who.role === 'admin' || who.role === 'guest') {
setStatus({ kind: 'ok', guestName: who.guest?.name ?? 'admin' });
} else {
setStatus('denied');
}
} catch {
if (!cancelled) setStatus('denied');
}
})();
return () => { cancelled = true; };
}, [params]);
if (status === 'checking') return <div className="min-h-screen flex items-center justify-center text-gray-500">Verificando convite</div>;
if (status === 'denied') return <div className="min-h-screen flex items-center justify-center text-center px-6">
<div>
<h1 className="text-2xl font-bold mb-2">Convite necessário</h1>
<p className="text-gray-500">Esta lista é por convite. Use o link que recebeu.</p>
</div>
</div>;
return <>{children}</>;
}

View File

@@ -1,7 +1,5 @@
'use client';
import { useAuth } from '@/lib/auth-context';
interface HeaderProps {
title: string;
subtitle?: string;
@@ -11,40 +9,38 @@ interface HeaderProps {
}
export default function Header({ title, subtitle, imageUrl, actions, maxWidth = 'max-w-7xl' }: HeaderProps) {
const { isAuthenticated } = useAuth();
return (
<>
{/* Admin Warning */}
{isAuthenticated && (
<div className="sticky top-0 z-50 bg-yellow-50 dark:bg-yellow-900 border-b border-yellow-200 dark:border-yellow-800">
<div className={`${maxWidth} mx-auto py-3 px-4 sm:px-6 lg:px-8`}>
<p className="text-center text-sm text-yellow-800 dark:text-yellow-200">
Atenção: visualizar listas públicas pode estragar surpresas
</p>
</div>
</div>
)}
<div className="relative overflow-hidden">
{/* Distant atmospheric haze */}
<div
aria-hidden
className="pointer-events-none absolute -top-16 -right-12 w-80 h-80 rounded-full opacity-50 blur-3xl"
style={{ background: 'radial-gradient(circle, var(--halo-1), transparent 65%)' }}
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-16 -left-10 w-72 h-72 rounded-full opacity-50 blur-3xl"
style={{ background: 'radial-gradient(circle, var(--halo-3), transparent 60%)' }}
/>
{/* Hero Section */}
<div className="bg-white dark:bg-gray-800 shadow-sm">
<div className={`${maxWidth} mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8`}>
<div className={`${maxWidth} mx-auto relative py-14 px-4 sm:py-20 sm:px-6 lg:px-8`}>
<div className="flex flex-col md:flex-row items-center md:items-start gap-8">
{imageUrl && (
<div className="md:w-64 flex-shrink-0">
<div className="md:w-56 flex-shrink-0">
<img
src={imageUrl}
alt={title}
className="w-64 h-64 object-cover rounded-lg shadow-lg"
className="w-56 h-56 object-cover rounded-3xl shadow-lifted ring-1 ring-[color:var(--border)]"
/>
</div>
)}
<div className={imageUrl ? 'flex-1 text-left' : 'flex-1 text-center'}>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-[color:var(--ink)] mb-4 leading-tight">
{title}
</h1>
{subtitle && (
<p className={`text-xl sm:text-2xl text-gray-600 dark:text-gray-300 mb-6 ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
<p className={`text-xl sm:text-2xl text-[color:var(--ink-soft)] mb-6 ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
{subtitle}
</p>
)}

View File

@@ -1,56 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export default function PasswordLockGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [isChecking, setIsChecking] = useState(true);
const [isLocked, setIsLocked] = useState(false);
useEffect(() => {
// Skip check for admin and lock pages
if (pathname.startsWith('/admin') || pathname === '/lock') {
setIsChecking(false);
return;
}
const checkPasswordLock = async () => {
try {
const response = await fetch('/api/settings');
const data = await response.json();
if (data.success && data.settings.passwordLockEnabled) {
setIsLocked(true);
// Redirect to lock page
router.push('/lock');
} else {
setIsChecking(false);
}
} catch (error) {
console.error('Error checking password lock:', error);
// On error, allow access to prevent site lockout
setIsChecking(false);
}
};
checkPasswordLock();
}, [pathname, router]);
// Show loading or nothing while checking
if (isChecking) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
// If locked, don't render children (redirect is happening)
if (isLocked) {
return null;
}
return <>{children}</>;
}

View File

@@ -1,30 +0,0 @@
'use client';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/admin/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}

View File

@@ -1,59 +0,0 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Load theme from localStorage
const savedTheme = localStorage.getItem('theme') as Theme | null;
const initialTheme = savedTheme || 'light';
setTheme(initialTheme);
document.documentElement.setAttribute('data-theme', initialTheme);
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

Binary file not shown.

View File

@@ -5,8 +5,7 @@ services:
container_name: chadebebe
restart: unless-stopped
environment:
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- COOKIE_SECURE=true
@@ -16,7 +15,7 @@ services:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.chadebebe.rule=Host(`chadebebe.omeu.website`)"
- "traefik.http.routers.chadebebe.rule=Host(`chadomartin.omeu.website`)"
- "traefik.http.routers.chadebebe.entrypoints=https"
- "traefik.http.routers.chadebebe.tls=true"
- "traefik.http.routers.chadebebe.tls.certresolver=letsencrypt"

View File

@@ -23,14 +23,23 @@ async function handleResponse<T>(response: Response): Promise<T> {
// Auth API
export const authApi = {
async login(username: string, password: string) {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
async session(payload: { adm?: string; usr?: string }) {
const response = await fetch(`${API_BASE_URL}/auth/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
body: JSON.stringify(payload),
});
return handleResponse<{ accessToken: string; refreshToken: string }>(response);
return handleResponse<{ success: true; role: 'admin' | 'guest'; guestId: string | null }>(response);
},
async whoami() {
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: 'include' });
return handleResponse<
| { role: 'admin'; guest: { id: string; name: string } | null }
| { role: 'guest'; guest: { id: string; name: string } }
| { role: 'none' }
>(response);
},
async logout() {
@@ -38,22 +47,52 @@ export const authApi = {
method: 'POST',
credentials: 'include',
});
return handleResponse<void>(response);
return handleResponse<{ success: true }>(response);
},
};
export interface Guest {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
export const guestsApi = {
async list() {
const response = await fetch(`${API_BASE_URL}/admin/guests`, { credentials: 'include' });
const data = await handleResponse<{ success: true; guests: Guest[] }>(response);
return data.guests;
},
async refresh() {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
async create(name: string) {
const response = await fetch(`${API_BASE_URL}/admin/guests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
});
return handleResponse<{ accessToken: string }>(response);
const data = await handleResponse<{ success: true; guest: Guest }>(response);
return data.guest;
},
async me() {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
async rename(id: string, name: string) {
const response = await fetch(`${API_BASE_URL}/admin/guests/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
});
const data = await handleResponse<{ success: true; guest: Guest }>(response);
return data.guest;
},
async delete(id: string) {
const response = await fetch(`${API_BASE_URL}/admin/guests/${id}`, {
method: 'DELETE',
credentials: 'include',
});
return handleResponse<{ username: string }>(response);
return handleResponse<{ success: true }>(response);
},
};
@@ -71,6 +110,15 @@ export interface Wishlist {
updatedAt: string;
}
export interface ItemClaim {
id: string;
quantity: number;
note: string | null;
isPurchased: boolean;
claimedAt: string;
guest: { id: string; name: string };
}
export interface Item {
id: string;
wishlistId: string;
@@ -82,10 +130,9 @@ export interface Item {
imageUrl: string | null;
purchaseUrls: Array<{ label: string; url: string }> | null;
isArchived: boolean;
claimedByName: string | null;
claimedByNote: string | null;
claimedAt: string | null;
isPurchased: boolean;
claims: ItemClaim[];
claimedQuantity: number;
remainingQuantity: number;
sortOrder: number;
createdAt: string;
updatedAt: string;
@@ -238,23 +285,24 @@ export const itemsApi = {
// Claiming API (public)
export const claimingApi = {
async claim(itemId: string, name?: string, note?: string) {
async claim(itemId: string, opts: { quantity?: number; note?: string } = {}) {
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, note }),
body: JSON.stringify({ quantity: opts.quantity ?? 1, note: opts.note }),
});
return handleResponse<{ claimToken: string; message: string }>(response);
return handleResponse<{ success: true; item: Item }>(response);
},
async unclaim(itemId: string) {
async unclaim(itemId: string, opts: { guestId?: string } = {}) {
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/unclaim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(opts.guestId ? { guestId: opts.guestId } : {}),
});
return handleResponse<{ success: boolean; message: string }>(response);
return handleResponse<{ success: true }>(response);
},
};
@@ -285,8 +333,6 @@ export const scrapingApi = {
export interface Settings {
siteTitle: string;
homepageSubtext: string;
passwordLockEnabled?: boolean;
passwordLock?: string;
}
export const settingsApi = {

View File

@@ -1,83 +0,0 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authApi } from './api';
import { useRouter } from 'next/navigation';
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
username: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Check authentication on mount using httpOnly cookies
useEffect(() => {
const initAuth = async () => {
try {
// Call /api/auth/me - cookies are sent automatically
const user = await authApi.me();
setIsAuthenticated(true);
setUsername(user.username);
} catch (error) {
// Not authenticated or session expired
setIsAuthenticated(false);
setUsername(null);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = async (username: string, password: string) => {
// Server sets httpOnly cookies automatically
await authApi.login(username, password);
setIsAuthenticated(true);
setUsername(username);
};
const logout = async () => {
try {
// Server clears httpOnly cookies
await authApi.logout();
} catch (error) {
// Continue with logout even if API call fails
}
setIsAuthenticated(false);
setUsername(null);
router.push('/');
};
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
username,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

39
lib/auth/cookies.ts Normal file
View File

@@ -0,0 +1,39 @@
export const ADM_COOKIE = 'adm_token';
export const USR_COOKIE = 'usr_token';
export function isSecureCookie(request: { headers: { get(name: string): string | null }; url: string }): boolean {
if (process.env.COOKIE_SECURE !== undefined) {
return process.env.COOKIE_SECURE === 'true';
}
if (process.env.NODE_ENV === 'production') {
return (
request.headers.get('x-forwarded-proto') === 'https' ||
request.url.startsWith('https://')
);
}
return false;
}
export function buildCookie(name: string, value: string, secure: boolean, maxAgeSeconds: number): string {
const parts = [
`${name}=${value}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Max-Age=${maxAgeSeconds}`,
];
if (secure) parts.push('Secure');
return parts.join('; ');
}
export function buildClearCookie(name: string, secure: boolean): string {
const parts = [
`${name}=`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=0',
];
if (secure) parts.push('Secure');
return parts.join('; ');
}

51
lib/auth/tokens.ts Normal file
View File

@@ -0,0 +1,51 @@
import { NextRequest } from 'next/server';
import { eq } from 'drizzle-orm';
import crypto from 'crypto';
import { db, guests, type Guest } from '@/lib/db';
import { ADM_COOKIE, USR_COOKIE } from './cookies';
function constantTimeEquals(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
export function getAdminToken(): string {
const t = process.env.ADMIN_TOKEN;
if (!t || t.length < 16) {
throw new Error('ADMIN_TOKEN env var must be set and at least 16 chars');
}
return t;
}
export function verifyAdminToken(req: NextRequest): boolean {
const cookie = req.cookies.get(ADM_COOKIE)?.value;
if (!cookie) return false;
try {
return constantTimeEquals(cookie, getAdminToken());
} catch {
return false;
}
}
export async function getGuestFromRequest(req: NextRequest): Promise<Guest | null> {
const cookie = req.cookies.get(USR_COOKIE)?.value;
if (!cookie) return null;
const rows = await db.select().from(guests).where(eq(guests.id, cookie)).limit(1);
return rows[0] ?? null;
}
export function isAdminTokenValue(token: string): boolean {
try {
return constantTimeEquals(token, getAdminToken());
} catch {
return false;
}
}
export async function isGuestTokenValue(token: string): Promise<Guest | null> {
if (!token) return null;
const rows = await db.select().from(guests).where(eq(guests.id, token)).limit(1);
return rows[0] ?? null;
}

View File

@@ -1,143 +0,0 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// Initialize secrets (auto-generate if not provided)
const secrets = initializeSecrets();
// Token expiry times
const TOKEN_EXPIRY = '72h';
const REFRESH_TOKEN_EXPIRY = '30d';
/**
* Initialize JWT secrets - auto-generate and persist if not provided in environment
*/
function initializeSecrets(): { secret: string; refreshSecret: string } {
const dataDir = path.join(process.cwd(), 'data');
const secretsFile = path.join(dataDir, 'secrets.json');
// If provided in environment, use those
if (process.env.SECRET) {
return {
secret: process.env.SECRET,
refreshSecret: process.env.REFRESH_SECRET || process.env.SECRET, // Use same secret if refresh not provided
};
}
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Try to load existing secrets
if (fs.existsSync(secretsFile)) {
try {
const data = JSON.parse(fs.readFileSync(secretsFile, 'utf-8'));
return data;
} catch {
// Failed to load, will generate new ones
}
}
// Generate new cryptographically secure secrets (512 bits each)
const newSecrets = {
secret: crypto.randomBytes(64).toString('hex'),
refreshSecret: crypto.randomBytes(64).toString('hex'),
};
// Save to file with restricted permissions
try {
fs.writeFileSync(secretsFile, JSON.stringify(newSecrets, null, 2), { mode: 0o600 });
} catch (error) {
console.error('⚠️ Failed to save secrets file:', error);
console.error('⚠️ WARNING: Using in-memory secrets - tokens will be invalid after restart!');
}
return newSecrets;
}
export interface TokenPayload {
username: string;
type: 'access' | 'refresh';
}
/**
* Generate an access token
*/
export function generateAccessToken(username: string): string {
return jwt.sign(
{ username, type: 'access' } as TokenPayload,
secrets.secret,
{ expiresIn: TOKEN_EXPIRY } as jwt.SignOptions
);
}
/**
* Generate a refresh token
*/
export function generateRefreshToken(username: string): string {
return jwt.sign(
{ username, type: 'refresh' } as TokenPayload,
secrets.refreshSecret,
{ expiresIn: REFRESH_TOKEN_EXPIRY } as jwt.SignOptions
);
}
/**
* Verify an access token
*/
export function verifyAccessToken(token: string): TokenPayload | null {
try {
const payload = jwt.verify(token, secrets.secret) as TokenPayload;
if (payload.type !== 'access') {
return null;
}
return payload;
} catch (error) {
return null;
}
}
/**
* Verify a refresh token
*/
export function verifyRefreshToken(token: string): TokenPayload | null {
try {
const payload = jwt.verify(token, secrets.refreshSecret) as TokenPayload;
if (payload.type !== 'refresh') {
return null;
}
return payload;
} catch (error) {
return null;
}
}
/**
* Validate admin credentials against environment variables
*/
export function validateAdminCredentials(username: string, password: string): boolean {
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword) {
console.error('❌ ADMIN_PASSWORD not set in environment variables');
return false;
}
return username === adminUsername && password === adminPassword;
}
export function isSecureCookie(request: { headers: { get(name: string): string | null }; url: string }): boolean {
if (process.env.COOKIE_SECURE !== undefined) {
return process.env.COOKIE_SECURE === 'true';
}
if (process.env.NODE_ENV === 'production') {
return (
request.headers.get('x-forwarded-proto') === 'https' ||
request.url.startsWith('https://')
);
}
return false;
}

View File

@@ -103,6 +103,35 @@ export async function initializeDatabase() {
)
`);
// Create guests table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS guests (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
)
`);
// Create item_claims table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS item_claims (
id TEXT PRIMARY KEY NOT NULL,
item_id TEXT NOT NULL,
guest_id TEXT NOT NULL,
quantity INTEGER DEFAULT 1 NOT NULL,
note TEXT,
is_purchased INTEGER DEFAULT 0 NOT NULL,
claimed_at INTEGER DEFAULT (unixepoch()) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (item_id) REFERENCES wishlist_items(id) ON DELETE CASCADE,
FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE CASCADE,
UNIQUE(item_id, guest_id)
)
`);
sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_item ON item_claims(item_id)');
sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_guest ON item_claims(guest_id)');
// Run migrations for existing databases
try {
const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>;
@@ -127,6 +156,52 @@ export async function initializeDatabase() {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT');
console.log('✅ Added preferences column to wishlists table');
}
const itemColumns = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>;
const hasClaimedByGuestId = itemColumns.some((col) => col.name === 'claimed_by_guest_id');
if (!hasClaimedByGuestId) {
sqlite.exec('ALTER TABLE wishlist_items ADD COLUMN claimed_by_guest_id TEXT REFERENCES guests(id) ON DELETE SET NULL');
console.log('✅ Added claimed_by_guest_id column to wishlist_items');
}
const hasClaimedByName = itemColumns.some((col) => col.name === 'claimed_by_name');
const hasClaimedByToken = itemColumns.some((col) => col.name === 'claimed_by_token');
if (hasClaimedByName || hasClaimedByToken) {
if (hasClaimedByName) {
sqlite.exec('UPDATE wishlist_items SET claimed_by_name = NULL');
}
if (hasClaimedByToken) {
sqlite.exec('UPDATE wishlist_items SET claimed_by_token = NULL');
}
console.log(' Legacy claim columns nullified (claimed_by_name / claimed_by_token)');
}
// Move any existing inline claims into item_claims (forward-migration), then nullify the inline columns
const itemColsAfter = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>;
const stillHasClaimedByGuestId = itemColsAfter.some((c) => c.name === 'claimed_by_guest_id');
if (stillHasClaimedByGuestId) {
sqlite.exec(`
INSERT INTO item_claims (id, item_id, guest_id, quantity, note, is_purchased, claimed_at, updated_at)
SELECT
lower(hex(randomblob(16))),
wi.id,
wi.claimed_by_guest_id,
1,
wi.claimed_by_note,
wi.is_purchased,
COALESCE(wi.claimed_at, unixepoch()),
unixepoch()
FROM wishlist_items wi
WHERE wi.claimed_by_guest_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM item_claims ic
WHERE ic.item_id = wi.id AND ic.guest_id = wi.claimed_by_guest_id
)
`);
sqlite.exec('UPDATE wishlist_items SET claimed_by_guest_id = NULL, claimed_by_note = NULL, claimed_at = NULL, is_purchased = 0');
console.log(' Migrated inline claims into item_claims and cleared inline claim columns');
}
} catch (migrationError) {
console.log('Migration already applied or not needed');
}

View File

@@ -1,23 +1,13 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, real, unique } from 'drizzle-orm/sqlite-core';
import { createId } from '@paralleldrive/cuid2';
import { sql } from 'drizzle-orm';
/**
* Database schema for family wishlist app
*
* Simplified for self-hosted family use:
* - No Settings table (use env vars instead)
* - Image URLs only (no upload handling in MVP)
* - Simplified purchase URLs (no usage tracking)
*/
// Wishlists table
export const wishlists = sqliteTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
description: text('description'),
preferences: text('preferences'), // General interests/likes section
preferences: text('preferences'),
imageUrl: text('image_url'),
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
@@ -25,7 +15,13 @@ export const wishlists = sqliteTable('wishlists', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Wishlist items table
export const guests = sqliteTable('guests', {
id: text('id').primaryKey().$defaultFn(() => createId()),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
export const wishlistItems = sqliteTable('wishlist_items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
@@ -34,27 +30,35 @@ export const wishlistItems = sqliteTable('wishlist_items', {
price: real('price'),
currency: text('currency').notNull().default('USD'),
quantity: integer('quantity').notNull().default(1),
imageUrl: text('images'), // Stored as 'images' in DB but exposed as imageUrl
imageUrl: text('images'),
purchaseUrls: text('purchase_urls', { mode: 'json' }).$type<Array<{
label: string;
url: string;
}>>(),
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
// Claim information
claimedByName: text('claimed_by_name'),
claimedByNote: text('claimed_by_note'),
claimedByToken: text('claimed_by_token').unique(),
claimedAt: integer('claimed_at', { mode: 'timestamp' }),
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
// claim ownership lives in item_claims (1 item -> many claims; unique per (item, guest))
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Settings table
export const itemClaims = sqliteTable(
'item_claims',
{
id: text('id').primaryKey().$defaultFn(() => createId()),
itemId: text('item_id').notNull().references(() => wishlistItems.id, { onDelete: 'cascade' }),
guestId: text('guest_id').notNull().references(() => guests.id, { onDelete: 'cascade' }),
quantity: integer('quantity').notNull().default(1),
note: text('note'),
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
claimedAt: integer('claimed_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
},
(t) => ({
uniqueItemGuest: unique('item_claims_item_guest_unique').on(t.itemId, t.guestId),
})
);
export const settings = sqliteTable('settings', {
id: text('id').primaryKey().$defaultFn(() => createId()),
key: text('key').notNull().unique(),
@@ -62,7 +66,8 @@ export const settings = sqliteTable('settings', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Type exports
export type Wishlist = typeof wishlists.$inferSelect;
export type WishlistItem = typeof wishlistItems.$inferSelect;
export type Guest = typeof guests.$inferSelect;
export type ItemClaim = typeof itemClaims.$inferSelect;
export type Setting = typeof settings.$inferSelect;

55
lib/items-with-claims.ts Normal file
View File

@@ -0,0 +1,55 @@
import { eq, inArray } from 'drizzle-orm';
import { db, wishlistItems, itemClaims, guests } from '@/lib/db';
type RawItem = typeof wishlistItems.$inferSelect;
export async function attachClaimsToItems(items: RawItem[]) {
if (items.length === 0) return [];
const itemIds = items.map((i) => i.id);
const rows = await db
.select({
id: itemClaims.id,
itemId: itemClaims.itemId,
quantity: itemClaims.quantity,
note: itemClaims.note,
isPurchased: itemClaims.isPurchased,
claimedAt: itemClaims.claimedAt,
guestId: guests.id,
guestName: guests.name,
})
.from(itemClaims)
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
.where(inArray(itemClaims.itemId, itemIds));
const byItem = new Map<string, ReturnType<typeof shapeClaim>[]>();
for (const r of rows) {
const arr = byItem.get(r.itemId) ?? [];
arr.push(shapeClaim(r));
byItem.set(r.itemId, arr);
}
return items.map((it) => {
const claims = byItem.get(it.id) ?? [];
const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0);
return {
...it,
claims,
claimedQuantity,
remainingQuantity: Math.max(0, it.quantity - claimedQuantity),
};
});
}
function shapeClaim(r: {
id: string; quantity: number; note: string | null; isPurchased: boolean;
claimedAt: Date; guestId: string; guestName: string;
}) {
return {
id: r.id,
quantity: r.quantity,
note: r.note,
isPurchased: r.isPurchased,
claimedAt: r.claimedAt,
guest: { id: r.guestId, name: r.guestName },
};
}

View File

@@ -11,7 +11,11 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx lib/db/seed.ts"
"db:seed": "tsx lib/db/seed.ts",
"guest": "tsx scripts/guest.ts",
"guest:create": "tsx scripts/guest.ts create",
"guest:list": "tsx scripts/guest.ts list",
"guest:delete": "tsx scripts/guest.ts delete"
},
"dependencies": {
"@lexical/html": "^0.39.0",

44
scripts/guest.ts Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env tsx
import { eq } from 'drizzle-orm';
import { db, guests, initializeDatabase } from '../lib/db';
function arg(name: string): string | undefined {
const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
if (idx < 0) return undefined;
const a = process.argv[idx];
if (a.includes('=')) return a.split('=')[1];
return process.argv[idx + 1];
}
async function main() {
await initializeDatabase();
const cmd = process.argv[2];
if (cmd === 'create') {
const name = (arg('name') ?? '').trim() || 'Convidado';
const [row] = await db.insert(guests).values({ name }).returning();
const base = process.env.PUBLIC_BASE_URL ?? 'http://localhost:3000';
console.log(JSON.stringify({ id: row.id, name: row.name, link: `${base}/?usr=${row.id}` }, null, 2));
return;
}
if (cmd === 'list') {
const rows = await db.select().from(guests);
console.log(JSON.stringify(rows, null, 2));
return;
}
if (cmd === 'delete') {
const id = arg('id');
if (!id) { console.error('--id is required'); process.exit(1); }
const out = await db.delete(guests).where(eq(guests.id, id)).returning();
if (out.length === 0) { console.error('not found'); process.exit(1); }
console.log('deleted');
return;
}
console.error('usage: guest <create --name=NAME | list | delete --id=ID>');
process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });