Prod hardening and K8s drafts: auth fail-close, DB pool, init/schema split, prod init guard

This commit is contained in:
2026-06-27 20:41:41 -05:00
parent 744830c3cd
commit 49bf370d41
9 changed files with 346 additions and 257 deletions
+77 -107
View File
@@ -329,14 +329,20 @@ export default function AdminPage() {
const savePlace = async (e: React.FormEvent) => {
e.preventDefault();
const payload = { ...placeForm, photos: placeForm.photos || [], featured_photo: placeForm.featured_photo || null, active: placeForm.active !== false, sort_order: placeForm.sort_order || 0 };
const payload = {
...placeForm,
photos: Array.isArray(placeForm.photos) ? placeForm.photos : [],
featured_photo: placeForm.featured_photo || null,
active: placeForm.active === true,
sort_order: placeForm.sort_order || 0,
};
if (editingPlace) {
await api(`/api/places/${editingPlace}`, 'PUT', payload);
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...payload } as Place : p)));
const r = await api(`/api/places/${editingPlace}`, 'PUT', payload);
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? ({ ...p, ...payload } as Place) : p)));
setEditingPlace(null);
} else {
const json = await api('/api/places', 'POST', payload);
setPlaces((prev) => [...prev, json.data]);
const r = await api('/api/places', 'POST', payload);
setPlaces((prev) => [...prev, r.data]);
}
setPlaceForm({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
notify('Place saved.');
@@ -490,7 +496,7 @@ export default function AdminPage() {
{error && <p style={{ color: '#c23b22', marginBottom: '1rem' }}>{error}</p>}
{message && <p style={{ color: '#3b7a22', marginBottom: '1rem' }}>{message}</p>}
<Section title="Brand Assets">
&lt;AccordionSection title="Brand Assets">
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr auto', alignItems: 'end' }}>
<Text label="Navbar logo" value={logoUrl} onChange={setLogoUrl} />
<Button onClick={() => updateAsset('logo', logoUrl)} disabled={!logoUrl}>Save logo</Button>
@@ -504,7 +510,7 @@ export default function AdminPage() {
</p>
</Section>
<Section title="Footer / Contact">
&lt;AccordionSection title="Footer / Contact">
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
<Text label="Facebook URL" value={footer.facebook_url} onChange={(v) => setFooter({ ...footer, facebook_url: v })} />
<Text label="Instagram URL" value={footer.instagram_url} onChange={(v) => setFooter({ ...footer, instagram_url: v })} />
@@ -518,7 +524,7 @@ export default function AdminPage() {
</div>
</Section>
<Section title="Image Gallery">
&lt;AccordionSection title="Image Gallery">
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1.25rem', background: gold, color: navy, borderRadius: '999px', cursor: 'pointer', fontWeight: 600, marginBottom: '1.5rem' }}>
+ Upload images
<input type="file" accept="image/*" multiple onChange={handleUpload} style={{ display: 'none' }} />
@@ -556,7 +562,7 @@ export default function AdminPage() {
)}
</Section>
<Section title="Homepage Slideshow">
&lt;AccordionSection title="Homepage Slideshow">
<form onSubmit={saveSlide} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Title" value={slideForm.title || ''} onChange={(v) => setSlideForm({ ...slideForm, title: v })} />
<Text label="Subtitle" value={slideForm.subtitle || ''} onChange={(v) => setSlideForm({ ...slideForm, subtitle: v })} />
@@ -583,7 +589,7 @@ export default function AdminPage() {
<ListTable rows={slides.map((s) => [s.title || '(untitled)', s.image_url ? '🖼️ image' : 'no image', s.active ? 'On' : 'Off', `#${s.sort_order}`])} onEdit={(i) => editSlide(slides[i])} onDelete={(i) => deleteSlide(slides[i].id)} />
</Section>
<Section title="Calendar Events">
&lt;AccordionSection title="Calendar Events">
<form onSubmit={saveEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Date" type="date" value={eventForm.date || ''} onChange={(v) => setEventForm({ ...eventForm, date: v })} />
<Text label="Title" value={eventForm.title || ''} onChange={(v) => setEventForm({ ...eventForm, title: v })} />
@@ -621,7 +627,7 @@ export default function AdminPage() {
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
</Section>
<Section title="Social Events">
&lt;AccordionSection title="Social Events">
<form onSubmit={saveSocialEvent} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Name" value={socialEventForm.name || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, name: v })} />
<Text label="Price" value={socialEventForm.price || ''} onChange={(v) => setSocialEventForm({ ...socialEventForm, price: v })} />
@@ -686,7 +692,7 @@ export default function AdminPage() {
)}
</Section>
<Section title="Rooms">
&lt;AccordionSection title="Rooms">
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
@@ -741,7 +747,7 @@ export default function AdminPage() {
)}
</Section>
<Section title="Menu">
&lt;AccordionSection title="Menu">
<form onSubmit={saveMenu} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Category" value={menuForm.category || ''} onChange={(v) => setMenuForm({ ...menuForm, category: v })} />
<Text label="Name" value={menuForm.name || ''} onChange={(v) => setMenuForm({ ...menuForm, name: v })} />
@@ -764,7 +770,7 @@ export default function AdminPage() {
<ListTable rows={menu.map((m) => [m.category, m.name, `$${m.price}`, m.is_daily_special ? '★ Daily' : '', m.active ? 'On' : 'Off', `#${m.sort_order}`])} onEdit={(i) => editMenu(menu[i])} onDelete={(i) => deleteMenu(menu[i].id)} />
</Section>
<Section title="Landscape Places">
&lt;AccordionSection title="Landscape Places">
<form onSubmit={savePlace} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Name" value={placeForm.name || ''} onChange={(v) => setPlaceForm({ ...placeForm, name: v })} />
<Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} />
@@ -791,7 +797,7 @@ export default function AdminPage() {
<ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.photos.length > 0 ? `🖼️ ${p.photos.length}` : 'no image', p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
</Section>
<Section title="Pending Reviews">
&lt;AccordionSection title="Pending Reviews">
{reviews.length === 0 ? <p style={{ color: '#777' }}>No pending reviews.</p> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{reviews.map((r) => (
@@ -807,101 +813,65 @@ export default function AdminPage() {
</div>
</div>
))}
</div>
)}
</Section>
function AccordionSection({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
return (
<section style={{ marginBottom: '2.5rem', background: '#fff', borderRadius: '12px', boxShadow: '0 1px 3px rgba(1,13,30,0.06)', padding: '1rem 1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
<h2 style={{ fontSize: 'clamp(20px, 3vw, 28px)', fontWeight: 400, color: navy, margin: 0 }}>{title}</h2>
<span style={{ fontSize: '13px', fontWeight: 600, color: '#555' }}>{open ? 'Minimize' : 'Maximize'}</span>
</div>
{open && <div style={{ marginTop: '0.25rem' }}>{children}</div>}
</section>
);
}
<Section title="Users">
<form onSubmit={saveUser} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Username" value={userForm.username} onChange={(v) => setUserForm({ ...userForm, username: v })} />
<Text label="Password" value={userForm.password} type="password" onChange={(v) => setUserForm({ ...userForm, password: v })} />
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: '14px', color: '#555' }}>
Role
<select value={userForm.role} onChange={(e) => setUserForm({ ...userForm, role: e.target.value as 'admin' | 'user' })} style={{ padding: '0.6rem 0.8rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)' }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<div style={{ display: 'flex', alignItems: 'end' }}>
<Button type="submit">Create user</Button>
</div>
</form>
<ListTable rows={users.map((u) => [u.username, u.role, u.first_name || '', u.last_name || '', u.comments_disabled ? 'comments disabled' : ''])} onDelete={(i) => deleteUser(users[i].id)} onEdit={(i) => { setEditingUser(users[i].id); setUserEditForm({ first_name: users[i].first_name || '', last_name: users[i].last_name || '', comments_disabled: users[i].comments_disabled }); }} />
{editingUser !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#fff', borderRadius: '12px', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<strong>Edit user {users.find((u) => u.id === editingUser)?.username}</strong>
<Text label="First name" value={userEditForm.first_name} onChange={(v) => setUserEditForm({ ...userEditForm, first_name: v })} />
<Text label="Last name" value={userEditForm.last_name} onChange={(v) => setUserEditForm({ ...userEditForm, last_name: v })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={userEditForm.comments_disabled} onChange={(e) => setUserEditForm({ ...userEditForm, comments_disabled: e.target.checked })} />
Disable comments
</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button onClick={() => { const u = users.find((u) => u.id === editingUser)!; saveUserNames(u); toggleUserComments(u); }}>Save</Button>
<SecondaryButton onClick={() => setEditingUser(null)}>Cancel</SecondaryButton>
</div>
</div>
)}
</Section>
</main>
);
}
function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
style={{
padding: '0.65rem 1.25rem',
background: disabled ? '#ccc' : gold,
color: navy,
border: 'none',
borderRadius: '999px',
cursor: disabled ? 'not-allowed' : 'pointer',
fontWeight: 600,
transition: 'filter 150ms',
}}
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = 'brightness(1.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.filter = 'brightness(1)'; }}
>
{children}
</button>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={{ marginBottom: '2.5rem' }}>
<h2 style={{ fontSize: 'clamp(20px, 3vw, 28px)', fontWeight: 400, color: navy, margin: '0 0 1rem' }}>{title}</h2>
{children}
</section>
);
}
function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) {
return (
<button
type={type}
onClick={onClick}
style={{
padding: '0.65rem 1.25rem',
background: 'transparent',
color: navy,
border: `1px solid ${navy}`,
borderRadius: '999px',
cursor: 'pointer',
fontWeight: 600,
}}
>
{children}
</button>
);
}
function Button({ children, type = 'button', disabled, onClick }: { children: React.ReactNode; type?: 'button' | 'submit'; disabled?: boolean; onClick?: () => void }) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
style={{
padding: '0.65rem 1.25rem',
background: disabled ? '#ccc' : gold,
color: navy,
border: 'none',
borderRadius: '999px',
cursor: disabled ? 'not-allowed' : 'pointer',
fontWeight: 600,
transition: 'filter 150ms',
}}
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = 'brightness(1.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.filter = 'brightness(1)'; }}
>
{children}
</button>
);
}
function SecondaryButton({ children, onClick, type = 'button' }: { children: React.ReactNode; onClick?: () => void; type?: 'button' | 'submit' }) {
return (
<button
type={type}
onClick={onClick}
style={{
padding: '0.65rem 1.25rem',
background: 'transparent',
color: navy,
border: `1px solid ${navy}`,
borderRadius: '999px',
cursor: 'pointer',
fontWeight: 600,
}}
>
{children}
</button>
);
}
function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
function DangerButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{ padding: '0.5rem 1rem', background: '#c23b22', color: '#fff', border: 'none', borderRadius: '999px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}
+6 -1
View File
@@ -1,7 +1,11 @@
import { NextResponse } from 'next/server';
import { initSchema, seed, resetDatabase } from '@/lib/schema';
import { initSchema, seed, resetDatabase, migrateFromLegacyPlaces } from '@/lib/schema';
export async function POST(request: Request) {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'Database initialization is disabled in production' }, { status: 403 });
}
try {
const { searchParams } = new URL(request.url);
const reset = searchParams.get('reset') === 'true';
@@ -12,6 +16,7 @@ export async function POST(request: Request) {
}
await initSchema();
await migrateFromLegacyPlaces();
await seed();
return NextResponse.json({ ok: true });
} catch (error) {