Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.
Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log
Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns
Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages
API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers
UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac
Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates
Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment
Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
setWaterLang,
|
||||
} from "@/actions/water-log/field";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { WaterGauge } from "@/components/water/icons";
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
@@ -345,10 +346,13 @@ function WaterFieldInner() {
|
||||
// Language selection screen
|
||||
if (step === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-xs text-center">
|
||||
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-stone-500 text-base">Loading...</p>
|
||||
<div className="mx-auto mb-4 flex justify-center">
|
||||
<WaterGauge size={64} level={null} status="open" />
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
|
||||
<p className="text-[#57694e] text-base font-medium">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -357,20 +361,31 @@ function WaterFieldInner() {
|
||||
// Language selection screen
|
||||
if (step === "lang") {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-xs">
|
||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
||||
<p className="text-stone-500 text-center mb-8">{t.selectLang}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="mb-3 flex justify-center">
|
||||
<WaterGauge size={56} level={null} status="open" />
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
|
||||
Tuxedo Ditch Co.
|
||||
</p>
|
||||
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => handleLangSelect("en")}
|
||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
|
||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLangSelect("es")}
|
||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
|
||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
|
||||
>
|
||||
Español
|
||||
</button>
|
||||
@@ -383,26 +398,31 @@ function WaterFieldInner() {
|
||||
// Role selection screen
|
||||
if (step === "role") {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-xs">
|
||||
<button
|
||||
onClick={() => setStep("lang")}
|
||||
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
|
||||
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
|
||||
>
|
||||
← {t.back}
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
||||
<p className="text-stone-500 text-center mb-8">{t.whoAreYou}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1
|
||||
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => { setStep("pin"); }}
|
||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
|
||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
|
||||
>
|
||||
Irrigator
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setStep("pin"); }}
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-stone-700 hover:bg-stone-800 min-h-[64px]"
|
||||
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
@@ -415,16 +435,21 @@ function WaterFieldInner() {
|
||||
// PIN entry screen
|
||||
if (step === "pin") {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-xs">
|
||||
<button
|
||||
onClick={() => setStep("role")}
|
||||
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
|
||||
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
|
||||
>
|
||||
← {t.back}
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
||||
<p className="text-stone-500 text-center mb-8">{t.enterPin}</p>
|
||||
<h1
|
||||
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
|
||||
|
||||
<form onSubmit={handlePinSubmit}>
|
||||
<input
|
||||
@@ -435,17 +460,17 @@ function WaterFieldInner() {
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t.pinPlaceholder}
|
||||
className="w-full rounded-xl border-2 border-stone-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-stone-900 mb-4"
|
||||
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
|
||||
style={{ letterSpacing: "0.3em" }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-center text-red-600 text-sm mb-4">{error}</p>
|
||||
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pin.length !== 4 || loading}
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 min-h-[64px]"
|
||||
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
|
||||
>
|
||||
{loading ? t.loggingIn : t.submit}
|
||||
</button>
|
||||
@@ -459,17 +484,27 @@ function WaterFieldInner() {
|
||||
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="min-h-screen bg-[#fdfaf2]">
|
||||
{/* Header */}
|
||||
<div className="bg-stone-900 px-4 py-4">
|
||||
<div className="bg-[#1a4d2e] px-4 py-4">
|
||||
<div className="mx-auto max-w-lg flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">{t.title}</h1>
|
||||
<p className="text-sm text-stone-400">{t.loggedInAs}: {irrigatorName}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<WaterGauge size={32} level={null} status="open" />
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl font-bold text-white"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-xs text-[#bbf7d0] font-mono uppercase tracking-wider">
|
||||
{t.loggedInAs}: {irrigatorName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-lg bg-stone-700 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-600 transition-colors min-h-[44px]"
|
||||
className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
|
||||
>
|
||||
{t.logout}
|
||||
</button>
|
||||
@@ -477,7 +512,7 @@ function WaterFieldInner() {
|
||||
</div>
|
||||
|
||||
{/* Step progress indicator */}
|
||||
<div className="bg-stone-100 border-b border-stone-200 px-4 py-2">
|
||||
<div className="bg-[#fafaf7] border-b border-[#d4d9d3] px-4 py-2">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{["lang", "role", "pin", "form"].map((s, i) => {
|
||||
@@ -486,8 +521,8 @@ function WaterFieldInner() {
|
||||
const isPast = stepIndex > i;
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-green-600" : isPast ? "bg-green-400" : "bg-stone-300"}`} />
|
||||
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-green-400" : "bg-stone-300"}`} />}
|
||||
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-[#1a4d2e]" : isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />
|
||||
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user