Files
route-commerce/src/app/admin/water-log/settings/page.tsx
T
Tyler 11cd2fd01a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
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).
2026-06-17 11:36:00 -06:00

346 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* /admin/water-log/settings
*
* Site-admin-facing settings page for the Water Log module. Controls:
* - Whether `/water/admin` is enabled
* - 4-digit admin PIN (regenerate on demand)
* - Session duration (hours)
* - Per-coarse-permission flags (edit / delete / export)
* - High/low alert phone + enable flag
*
* Visual language: Field Almanac. Same cream + forest palette as
* the main WaterLogAdminPanel, but trimmed to a single column for
* a focused "form" feel.
*/
import { useState, useEffect, useCallback } from "react";
import {
getWaterAdminSettings,
saveWaterAdminSettings,
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function WaterLogSettingsPage() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [revealedPin, setRevealedPin] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state
const [enabled, setEnabled] = useState(false);
const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(true);
const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
const loadSettings = useCallback(async () => {
setLoading(true);
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
if (data) {
setSettings(data);
setEnabled(data.enabled);
setSessionDuration(data.sessionDurationHours);
setCanEdit(data.canEditEntries);
setCanDelete(data.canDeleteEntries);
setCanExport(data.canExportCsv);
setAlertPhone(data.alertPhone ?? "");
setAlertsEnabled(data.alertsEnabled);
}
setLoading(false);
}, []);
useEffect(() => {
// Load on mount — setState-in-effect is intentional here
// because we're hydrating from server data.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadSettings();
}, [loadSettings]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMessage(null);
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled,
sessionDurationHours: sessionDuration,
canEditEntries: canEdit,
canDeleteEntries: canDelete,
canExportCsv: canExport,
alertPhone: alertPhone || null,
alertsEnabled,
});
if (result.success) {
setMessage({ type: "success", text: "Settings saved" });
if (result.settings) setSettings(result.settings);
} else {
setMessage({ type: "error", text: result.error ?? "Save failed" });
}
setSaving(false);
}
async function handleRegenerate() {
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
return;
}
setRegenerating(true);
setMessage(null);
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
if (result.success && result.pin) {
setRevealedPin(result.pin);
setMessage({
type: "success",
text: "New PIN generated. Save it now — it will not be shown again.",
});
} else {
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
}
setRegenerating(false);
}
if (loading) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
<span className="text-[#5a5d5a]">Loading</span>
</div>
);
}
return (
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
{/* Header */}
<div className="border-b border-[#d4d9d3] bg-white">
<div className="mx-auto max-w-2xl px-6 py-6">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 Settings</p>
<h1
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Water Log · Admin Portal
</h1>
<p className="mt-2 text-sm text-[#5a5d5a]">
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
This is separate from the platform admin login.
</p>
</div>
</div>
<div className="mx-auto max-w-2xl px-6 py-8">
<form onSubmit={handleSave} className="space-y-6">
{message && (
<div
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
message.type === "success"
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
}`}
>
{message.text}
</div>
)}
{/* Enable toggle */}
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
<ToggleRow
label="Enable"
description="Allow water admins to sign in with a 4-digit PIN."
value={enabled}
onChange={setEnabled}
/>
</Card>
{enabled && (
<>
{/* PIN */}
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
{revealedPin ? (
<div className="space-y-3">
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
New PIN write it down
</p>
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
<span
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
>
{revealedPin}
</span>
</div>
<button
type="button"
onClick={() => setRevealedPin(null)}
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
>
I&apos;ve saved it dismiss
</button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-[#5a5d5a]">
{settings?.pin === null
? "Generate a new PIN to give to your water admin."
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
</p>
<button
type="button"
onClick={handleRegenerate}
disabled={regenerating}
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
>
{regenerating ? "Generating…" : "Regenerate PIN"}
</button>
</div>
)}
</Card>
{/* Session Duration */}
<Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1168 hours)">
<div className="flex items-center gap-4">
<input
type="range"
min={1}
max={168}
value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
className="flex-1 accent-[#1a4d2e]"
aria-label="Session duration in hours"
/>
<span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
{sessionDuration}h
</span>
</div>
</Card>
{/* Permissions */}
<Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
<div className="divide-y divide-[#d4d9d3]">
<ToggleRow
label="Edit entries"
description="Allow modifying existing log entries."
value={canEdit}
onChange={setCanEdit}
/>
<ToggleRow
label="Delete entries"
description="Allow removing log entries."
value={canDelete}
onChange={setCanDelete}
/>
<ToggleRow
label="Export CSV"
description="Allow exporting water log data."
value={canExport}
onChange={setCanExport}
/>
</div>
</Card>
{/* High/Low Alerts */}
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
<ToggleRow
label="Enable alerts"
description="Threshold breach notifications."
value={alertsEnabled}
onChange={setAlertsEnabled}
/>
{alertsEnabled && (
<div className="mt-4">
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
Alert phone number
</label>
<input
id="water-alert-phone"
type="tel"
value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)}
placeholder="+13035551234"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
autoComplete="tel"
/>
<p className="mt-1 text-xs text-[#8a8b88]">
U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
</p>
</div>
)}
</Card>
</>
)}
<button
type="submit"
disabled={saving}
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
>
{saving ? "Saving…" : "Save Settings"}
</button>
</form>
</div>
</div>
);
}
function Card({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
<header className="mb-4">
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
</header>
{children}
</section>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
{description && <p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>}
</div>
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(!value)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
);
}