Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
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:
Tyler
2026-06-17 11:36:00 -06:00
parent 52c8a71cd0
commit 11cd2fd01a
22 changed files with 4944 additions and 1137 deletions
File diff suppressed because it is too large Load Diff
+399 -62
View File
@@ -1,88 +1,374 @@
/**
* Water Log — field (PIN) actions.
*
* These power `/water` and `/water/admin` — the mobile-first, PIN-only
* portals that ditch-riders actually use in the field. The shape of
* the data they consume is the same as admin actions, but the
* authorization model is different:
*
* - Irrigators carry a `wl_session` cookie tied to a row in
* `water_sessions`. Cookie expiry = row's `expires_at`.
* - Admins (water_admin role) carry a `wl_admin_session` cookie
* tied to `water_admin_sessions`.
*
* Both cookies are httpOnly + sameSite=lax. The session lookup is the
* gate for every mutating action — there's no role/permission check
* inside the action because the cookie presence + the row's role is
* the permission.
*/
"use server";
import { cookies } from "next/headers";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterSessions,
waterLogEntries,
waterAdminSessions,
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
// `submit_water_entry`, `trigger_water_alert`,
// `get_water_admin_session`) and tables (`water_headgates`,
// `water_users`, `water_sessions`, `water_admin_sessions`,
// `water_entries`, `water_alert_log`) that are not in the SaaS
// rebuild's `db/schema/`. The actions below preserve the original
// signatures and return empty / no-op responses so the field UI
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
const FIELD_SESSION_HOURS = 8;
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
type VerifyPinResult = {
success: true;
user_id: string;
name: string;
role: string;
session_id: string;
lang: string;
} | {
success: false;
error: string;
};
// ── Types ──────────────────────────────────────────────────────────────────
type SubmitEntryResult = {
success: true;
entry_id: string;
} | {
success: false;
error: string;
};
type Headgate = {
type FieldHeadgate = {
id: string;
name: string;
unit: string;
status: string;
high_threshold: number | null;
low_threshold: number | null;
active: boolean;
created_at: string;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
type VerifyPinResult =
| {
success: true;
user_id: string;
name: string;
role: "irrigator" | "water_admin";
session_id: string;
lang: string;
}
| { success: false; error: string };
type SubmitEntryResult =
| { success: true; entry_id: string }
| { success: false; error: string };
// ── Headgates (read-only for field) ────────────────────────────────────────
export async function getWaterHeadgates(
_brandId: string,
_activeOnly = false
): Promise<Headgate[]> {
return [];
activeOnly: boolean = false,
): Promise<FieldHeadgate[]> {
return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
eq(waterHeadgates.active, true),
)
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
const rows = await db
.select()
.from(waterHeadgates)
.where(where)
.orderBy(waterHeadgates.name);
return rows.map((h) => ({
id: h.id,
name: h.name,
unit: h.unit,
status: h.status,
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
active: h.active,
}));
});
}
// ── PIN verify + session ───────────────────────────────────────────────────
export async function verifyWaterPin(
_brandId: string,
_pin: string
pin: string,
): Promise<VerifyPinResult> {
return { success: false, error: NOT_CONFIGURED };
}
// Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
export async function submitWaterEntry(
_headgateId: string,
_measurement: number,
_unit: string,
_notes: string,
_photoUrl?: string,
_latitude?: number,
_longitude?: number,
_headgateLocked?: boolean
): Promise<SubmitEntryResult> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
// Look the irrigator up across all brands (PIN is the only credential).
const lookup = await withPlatformAdmin(async (db) => {
const rows = await db
.select()
.from(waterIrrigators)
.where(eq(waterIrrigators.active, true))
.orderBy(waterIrrigators.name);
return rows;
});
if (!sessionId) {
return { success: false, error: "Not logged in" };
// Constant-time-ish: check every row, only succeed on the first match.
// (We can't make this perfectly constant-time without pulling the
// hashed values into memory in random order, but iterating by name
// is close enough for a 4-digit PIN and avoids the worst case of an
// obvious timing oracle on a sorted list.)
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
if (!match) {
// Add a small delay to discourage brute-force scanning.
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
return { success: false, error: NOT_CONFIGURED };
// Create a session in the user's brand context.
const sessionId = await withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [row] = await db
.insert(waterSessions)
.values({
irrigatorId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!row) throw new Error("Failed to create session");
// Bump last_used_at for "I haven't seen this person in a while" UX
await db
.update(waterIrrigators)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id));
return row.id;
});
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: FIELD_SESSION_HOURS * 3600,
path: "/",
});
return {
success: true,
user_id: match.id,
name: match.name,
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
session_id: sessionId,
lang: match.languagePreference,
};
}
// ── Submit entry ───────────────────────────────────────────────────────────
export async function submitWaterEntry(
headgateId: string,
measurement: number,
unit: string,
notes: string,
photoUrl?: string,
latitude?: number,
longitude?: number,
_headgateLocked?: boolean,
): Promise<SubmitEntryResult> {
// ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
// ── Validate input ──
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
}
if (measurement > 1_000_000) {
return { success: false, error: "Measurement is implausibly large" };
}
if (notes && notes.length > 500) {
return { success: false, error: "Notes are too long (max 500 chars)" };
}
if (latitude != null && (latitude < -90 || latitude > 90)) {
return { success: false, error: "Invalid latitude" };
}
if (longitude != null && (longitude < -180 || longitude > 180)) {
return { success: false, error: "Invalid longitude" };
}
// ── Write the entry inside the brand context ──
return withBrand(session.brandId, async (db) => {
// Confirm the headgate belongs to this brand
const headgateRows = await db
.select()
.from(waterHeadgates)
.where(eq(waterHeadgates.id, headgateId))
.limit(1);
const headgate = headgateRows[0];
if (!headgate) return { success: false, error: "Headgate not found" };
// Auto-calc total_gallons when CFS × duration is provided.
// Caller passes `measurement` as the total CFS volume for the set
// (we don't track duration separately on the entry — duration goes
// in notes if needed). We leave total_gallons null for CFS by
// default to avoid spurious "X gallons" claims.
const totalGallons = computeTotalGallons(measurement, unit);
const now = new Date();
const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
const [entry] = await db
.insert(waterLogEntries)
.values({
brandId: session.brandId,
headgateId,
irrigatorId: session.userId,
measurement: measurement.toString(),
unit,
totalGallons: totalGallons?.toString() ?? null,
method: "manual",
notes: notes || null,
submittedVia: "field",
photoUrl: photoUrl ?? null,
latitude: latitude ?? null,
longitude: longitude ?? null,
loggedDate,
loggedAt: now,
})
.returning({ id: waterLogEntries.id });
if (!entry) return { success: false, error: "Insert failed" };
// Bump headgate last_used_at
await db
.update(waterHeadgates)
.set({ lastUsedAt: now })
.where(eq(waterHeadgates.id, headgateId));
// Threshold alert check (fire-and-forget)
if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "high",
reading: measurement,
threshold: Number(headgate.highThreshold),
headgateName: headgate.name,
});
}
if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "low",
reading: measurement,
threshold: Number(headgate.lowThreshold),
headgateName: headgate.name,
});
}
return { success: true, entry_id: entry.id };
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
export async function requireFieldSession(): Promise<FieldSession> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
try {
await withPlatformAdmin(async (db) => {
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
});
} catch {
// ignore — the cookie is being deleted anyway
}
}
cookieStore.delete("wl_session");
}
export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
await withPlatformAdmin(async (db) => {
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId));
});
} catch {
// ignore — cookie is being deleted anyway
}
}
cookieStore.delete("wl_admin_session");
}
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
return cookieStore.get("wl_session")?.value ?? null;
}
/**
* Resolves the current `/water/admin` session.
*
* Returns the admin role + brandId on success, or `null` if the cookie
* is missing / expired / points at a deleted session row. Used by
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
* gate on top of `getAdminUser()` — a site admin can edit entries
* directly, but a PIN-authenticated water admin can too.
*/
export async function getWaterAdminSession(): Promise<{
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return {
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
role: "water_admin" as const,
};
});
}
export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, {
httpOnly: false,
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
});
}
export async function getWaterAdminSession(): Promise<{
user_id: string;
name: string;
role: string;
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
// ── Internal helpers ──────────────────────────────────────────────────────
/**
* If the measurement is in CFS (cubic feet per second) and we know it
* represents a set duration, we can derive total gallons. Without
* duration, we leave total_gallons null to avoid making up numbers.
*
* CFS × 60s × 7.48052 = gallons per minute.
* 1 CFS for 1 hour = ~448.83 gallons.
*/
function computeTotalGallons(measurement: number, unit: string): number | null {
if (unit !== "CFS") return null;
// We don't have a duration field on the entry, so we return null. The
// admin can edit an entry to set gallons manually if they have that
// data. The hook is here so callers can extend easily.
return null;
}
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
+228 -28
View File
@@ -1,44 +1,244 @@
/**
* Water Log — admin settings (PIN-protected /water/admin portal).
*
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
* from the irrigators' PIN). That PIN is *hashed* and stored in
* `water_admin_settings` (one row per brand). Sessions are tracked in
* `water_admin_sessions`.
*
* There is currently no per-admin-user PIN — a single brand-wide admin
* PIN. If you need per-user PINs (e.g. for an audit trail of which
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
* key sessions by `admin_user_id`.
*/
"use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterAdminSettings,
waterAdminSessions,
type WaterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
// `hash_water_admin_pin`, `save_water_admin_settings`,
// `verify_water_admin_pin`) and the underlying
// `water_admin_settings` table are not in the SaaS rebuild schema.
// The functions below preserve the original signatures and return
// empty / no-op responses. Same pattern as
// `actions/route-trace/lots.ts`.
export type WaterAdminSettings = {
export type AdminSettings = {
enabled: boolean;
session_duration_hours: number;
can_edit_entries: boolean;
can_delete_entries: boolean;
can_export_csv: boolean;
alert_phone?: string | null;
alerts_enabled?: boolean;
sessionDurationHours: number;
canEditEntries: boolean;
canDeleteEntries: boolean;
canExportCsv: boolean;
alertPhone: string | null;
alertsEnabled: boolean;
/** The currently configured PIN, in plain text. Only returned on
* read right after the admin regenerates it; otherwise null. */
pin: string | null;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
function mapSettings(s: WaterAdminSettings): AdminSettings {
return {
enabled: s.enabled,
sessionDurationHours: s.sessionDurationHours,
canEditEntries: s.canEditEntries,
canDeleteEntries: s.canDeleteEntries,
canExportCsv: s.canExportCsv,
alertPhone: s.alertPhone,
alertsEnabled: s.alertsEnabled,
pin: null, // never returned unless just regenerated
};
}
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
return null;
export async function getWaterAdminSettings(
brandId: string,
): Promise<AdminSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return null;
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
if (!rows[0]) {
// Lazy-initialize default settings the first time the page is hit
const [created] = await db
.insert(waterAdminSettings)
.values({ brandId })
.returning();
if (!created) return null;
return { ...mapSettings(created), pin: null };
}
return mapSettings(rows[0]);
});
}
export async function saveWaterAdminSettings(
_brandId: string,
_settings: Partial<WaterAdminSettings & { pin?: string }>
): Promise<{ success: boolean; error?: string }> {
brandId: string,
settings: Partial<AdminSettings>,
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
if (
settings.sessionDurationHours != null &&
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
) {
return { success: false, error: "Session duration must be 1168 hours" };
}
return withBrand(brandId, async (db) => {
const update: Partial<WaterAdminSettings> = {};
if (settings.enabled != null) update.enabled = settings.enabled;
if (settings.sessionDurationHours != null)
update.sessionDurationHours = settings.sessionDurationHours;
if (settings.canEditEntries != null)
update.canEditEntries = settings.canEditEntries;
if (settings.canDeleteEntries != null)
update.canDeleteEntries = settings.canDeleteEntries;
if (settings.canExportCsv != null)
update.canExportCsv = settings.canExportCsv;
if (settings.alertPhone !== undefined)
update.alertPhone = settings.alertPhone;
if (settings.alertsEnabled != null)
update.alertsEnabled = settings.alertsEnabled;
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
// Upsert: insert if missing, update if present.
const updated = await db
.insert(waterAdminSettings)
.values({ brandId, ...update })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: update,
})
.returning();
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "update",
entityType: "settings",
details: { ...update },
});
return { success: true, settings: mapSettings(updated[0]) };
});
}
export async function verifyWaterAdminPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session_id?: string; error?: string }> {
return { success: false, error: NOT_CONFIGURED };
/**
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
* plaintext PIN *once* — caller is responsible for showing it to the
* admin and never persisting it.
*/
export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
await db
.insert(waterAdminSettings)
.values({ brandId, pinHash })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
});
// Invalidate any existing admin sessions for safety
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.brandId, brandId));
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "regenerate_admin_pin",
entityType: "settings",
});
return { success: true, pin };
});
}
/**
* PIN verify for the `/water/admin` portal. Creates a session row and
* sets the `wl_admin_session` cookie.
*/
export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
const settings = rows[0];
if (!settings) {
return { success: false, error: "Admin portal not configured" };
}
if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" };
}
if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
}
if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
// Tie the session to the calling site admin (best-effort).
const adminUser = await getAdminUser();
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
);
const [session] = await db
.insert(waterAdminSessions)
.values({
brandId,
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
pinHashUsed: settings.pinHash,
expiresAt,
})
.returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" };
const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600,
path: "/",
});
return { success: true, session_id: session.id };
});
}
+246 -169
View File
@@ -1,25 +1,43 @@
"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, type WaterAdminSettings } from "@/actions/water-log/settings";
import { useRouter } from "next/navigation";
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 router = useRouter();
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
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 [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(false);
const [canDelete, setCanDelete] = useState(true);
const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
if (data) {
setSettings(data);
setEnabled(data.enabled);
setSessionDuration(data.session_duration_hours);
setCanEdit(data.can_edit_entries);
setCanDelete(data.can_delete_entries);
setCanExport(data.can_export_csv);
setAlertPhone(data.alert_phone ?? "");
setAlertsEnabled(data.alerts_enabled ?? false);
setSessionDuration(data.sessionDurationHours);
setCanEdit(data.canEditEntries);
setCanDelete(data.canDeleteEntries);
setCanExport(data.canExportCsv);
setAlertPhone(data.alertPhone ?? "");
setAlertsEnabled(data.alertsEnabled);
}
setLoading(false);
}, []);
useEffect(() => {
const init = async () => {
await loadSettings();
};
init();
// 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();
if (enabled && pin && pin !== confirmPin) {
setMessage({ type: "error", text: "PINs do not match" });
return;
}
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
return;
}
setSaving(true);
setMessage(null);
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled,
pin: pin || undefined,
session_duration_hours: sessionDuration,
can_edit_entries: canEdit,
can_delete_entries: canDelete,
can_export_csv: canExport,
alert_phone: alertPhone || null,
alerts_enabled: alertsEnabled,
sessionDurationHours: sessionDuration,
canEditEntries: canEdit,
canDeleteEntries: canDelete,
canExportCsv: canExport,
alertPhone: alertPhone || null,
alertsEnabled,
});
if (result.success) {
setMessage({ type: "success", text: "Settings saved" });
setPin("");
setConfirmPin("");
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-zinc-950 flex items-center justify-center">
<span className="text-zinc-500">Loading...</span>
<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-zinc-950">
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-lg">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm"> Back</button>
<div>
<h1 className="text-xl font-bold text-zinc-100">Water Log Admin Portal</h1>
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
</div>
</div>
<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-lg px-6 py-6">
<div className="mx-auto max-w-2xl px-6 py-8">
<form onSubmit={handleSave} className="space-y-6">
{message && (
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
<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 */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
</div>
<button
type="button"
onClick={() => setEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
</div>
<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 */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
<input
id="water-new-pin"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
<div>
<label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
<input
id="water-confirm-pin"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
</div>
</div>
{/* Session Duration */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
<p className="font-semibold text-zinc-100">Session Duration</p>
<p className="text-xs text-zinc-500">How long the admin session lasts after login (172 hours).</p>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={72}
value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
className="flex-1"
/>
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
</div>
</div>
{/* Permissions */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
<p className="font-semibold text-zinc-100">Permissions</p>
{[
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit },
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete },
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport },
].map(({ key, label, desc, value, setter }) => (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">{label}</p>
<p className="text-xs text-zinc-500">{desc}</p>
<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={() => setter((v: boolean) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
onClick={() => setRevealedPin(null)}
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
I&apos;ve saved it dismiss
</button>
</div>
))}
</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 */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate&apos;s thresholds.</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p>
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
</div>
<button
type="button"
onClick={() => setAlertsEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<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>
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
<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="+1234567890"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
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="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
<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>
)}
</div>
{/* QR hint */}
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
<p className="text-xs text-amber-700">
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
</p>
</div>
</Card>
</>
)}
<button
type="submit"
disabled={saving}
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
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"}
{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>
);
}
+36 -42
View File
@@ -1,52 +1,46 @@
/**
* POST /api/water-admin-auth
*
* Body: { brandId: string, pin: string }
* Side effect: sets the `wl_admin_session` cookie on success.
*
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
* itself is created by `verifyWaterAdminPin()` in
* `src/actions/water-log/settings.ts`, which is the single source of
* truth for admin PIN verification.
*/
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { pool } from "@/lib/db";
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
import { captureError } from "@/lib/sentry";
export async function POST(request: Request) {
try {
const { brandId, pin } = await request.json();
const { brandId, pin } = (await request.json()) as {
brandId?: string;
pin?: string;
};
if (!brandId || !pin) {
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
return NextResponse.json(
{ success: false, error: "Missing brandId or pin" },
{ status: 400 },
);
}
// Get admin settings
const settingsRes = await pool.query<{
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
}>(
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
[brandId],
);
const settings = settingsRes.rows[0]?.get_water_admin_settings;
if (!settings?.enabled) {
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
const result = await verifyWaterAdminPin(brandId, pin);
if (!result.success) {
// Don't leak whether the PIN was wrong vs. not configured — both
// are the same to a field attacker probing the endpoint.
const status = result.error === "Invalid PIN" ? 401 : 403;
return NextResponse.json(
{ success: false, error: "Invalid PIN" },
{ status },
);
}
// Verify PIN
const verifyRes = await pool.query<{
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
}>(
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
[brandId, pin],
);
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
if (!verifyData?.success || !verifyData.session_id) {
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
}
// Create session cookie
const sessionId = verifyData.session_id;
const cookieStore = await cookies();
const durationHours = settings.session_duration_hours ?? 4;
cookieStore.set("wl_admin_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: durationHours * 3600,
path: "/",
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
} catch (err) {
captureError(err as Error, { path: "/api/water-admin-auth" });
return NextResponse.json(
{ success: false, error: "Server error" },
{ status: 500 },
);
}
}
+61 -35
View File
@@ -1,60 +1,86 @@
/**
* GET /api/water-logs/export
*
* Streams the water log as JSON or CSV. Admin-only — checked via
* `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin
* session, with Tuxedo fallback for backward compat.
*
* GET /api/water-logs/export?format=csv
*/
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getWaterEntries } from "@/actions/water-log/admin";
import type { WaterLogReportRow } from "@/lib/water-log-reporting";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export async function GET(request: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_water_log) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const format = searchParams.get("format") ?? "json";
const brandId =
adminUser.brand_id ??
process.env.TUXEDO_BRAND_ID ??
TUXEDO_BRAND_ID;
// Use brand_id from session (always Tuxedo for water log) or fallback to env
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
type WaterEntry = {
id: string;
user_id: string | null;
headgate_id: string | null;
measurement: number | null;
unit: string | null;
notes: string | null;
created_at: string;
};
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
[brandId, 10000],
);
const entries = data[0]?.get_water_entries ?? [];
const raw = await getWaterEntries(brandId, 10000);
const rows: WaterLogReportRow[] = raw.map((r) => ({
logged_at: r.logged_at,
headgate_name: r.headgate_name,
user_name: r.user_name,
user_role: "irrigator",
measurement: r.measurement,
unit: r.unit,
notes: r.notes,
submitted_via: r.submitted_via,
}));
if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
const csvRows = [headers.join(",")];
for (const row of entries) {
csvRows.push([
row.id,
row.user_id ?? "",
row.headgate_id ?? "",
row.measurement ?? "",
row.unit ?? "",
`"${(row.notes ?? "").replace(/"/g, '""')}"`,
row.created_at ?? "",
].join(","));
const headers = [
"When",
"Headgate",
"User",
"Measurement",
"Unit",
"Total Gallons",
"Method",
"Notes",
"Via",
"Photo URL",
];
const esc = (s: string | null | undefined) =>
`"${(s ?? "").replace(/"/g, '""')}"`;
const lines: string[] = [headers.join(",")];
for (const e of raw) {
lines.push(
[
esc(e.logged_at),
esc(e.headgate_name),
esc(e.user_name),
String(e.measurement),
esc(e.unit),
e.total_gallons != null ? String(e.total_gallons) : "",
esc(e.method),
esc(e.notes),
esc(e.submitted_via),
esc(e.photo_url),
].join(","),
);
}
return new NextResponse(csvRows.join("\n"), {
return new NextResponse(lines.join("\n"), {
headers: {
"Content-Type": "text/csv",
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
}
return NextResponse.json(entries);
return NextResponse.json({ rows });
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -361,8 +361,8 @@ export default function WaterAdminClient() {
setSavingUser(true);
setNewUserError(null);
const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang);
if (result.success && result.pin) {
setNewPin({ name: result.irrigator!.name, pin: result.pin });
if (result.success && result.pin && result.user) {
setNewPin({ name: result.user.name, pin: result.pin });
setNewUserName("");
setShowAddUser(false);
const updated = await getWaterIrrigators(TUXEDO_BRAND_ID);
+67 -32
View File
@@ -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>
);
})}
+85
View File
@@ -0,0 +1,85 @@
/**
* SeasonMark — small inline sun / snowflake indicator used in the
* "Today's Summary" card to signal irrigation season status. Drawn as
* a flat 16px mark; uses the project's existing color tokens so it
* works in light and dark contexts.
*/
type Props = {
inSeason: boolean;
size?: number;
className?: string;
title?: string;
};
export function SeasonMark({
inSeason,
size = 16,
className = "",
title,
}: Props) {
const stroke = "#1a4d2e";
if (inSeason) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
className={className}
role="img"
aria-label={title ?? "In irrigation season"}
style={{ display: "inline-block", flexShrink: 0 }}
>
<circle cx="8" cy="8" r="2.8" fill="#facc15" stroke={stroke} strokeWidth="1" />
{[
[8, 1],
[8, 15],
[1, 8],
[15, 8],
[3, 3],
[13, 13],
[3, 13],
[13, 3],
].map(([cx, cy], i) => (
<line
key={i}
x1={8}
y1={8}
x2={cx}
y2={cy}
stroke={stroke}
strokeWidth="1"
strokeLinecap="round"
opacity="0.7"
/>
))}
</svg>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
className={className}
role="img"
aria-label={title ?? "Outside irrigation season"}
style={{ display: "inline-block", flexShrink: 0 }}
>
<g stroke="#1e3a8a" strokeWidth="1.2" strokeLinecap="round" fill="none">
<line x1="8" y1="1" x2="8" y2="15" />
<line x1="1" y1="8" x2="15" y2="8" />
<line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
<line x1="2.5" y1="13.5" x2="13.5" y2="2.5" />
{/* Arrow heads */}
<polyline points="6.5,2.5 8,1 9.5,2.5" />
<polyline points="6.5,13.5 8,15 9.5,13.5" />
<polyline points="2.5,6.5 1,8 2.5,9.5" />
<polyline points="13.5,6.5 15,8 13.5,9.5" />
<polyline points="4,4 2.5,2.5 4,1" />
<polyline points="12,4 13.5,2.5 12,1" />
<polyline points="4,12 2.5,13.5 4,15" />
<polyline points="12,12 13.5,13.5 12,15" />
</g>
</svg>
);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* WaterGauge — a stylized "ditch gauge" SVG used as a visual signature
* for the Water Log feature. Renders a vertical pipe with a water level
* and a small headgate at the top. Deliberately drawn with a slight
* hand-sketched quality (rough strokes, no perfectly straight lines)
* to evoke a field-notebook annotation rather than a slick dashboard
* chart.
*
* <WaterGauge size={48} level={0.7} status="open" />
* <WaterGauge size={24} level={null} status="closed" />
*/
type Props = {
size?: number;
level?: number | null; // 0..1
status?: "open" | "closed" | "maintenance";
className?: string;
ariaLabel?: string;
};
export function WaterGauge({
size = 32,
level = 0.5,
status = "open",
className = "",
ariaLabel = "Water gauge",
}: Props) {
const w = size;
const h = size * 1.25;
const stroke = Math.max(1, size / 16);
// Clamp + choose fill level
const l =
level == null
? status === "closed"
? 0
: 0.45
: Math.max(0, Math.min(1, level));
// Color reflects the operational state
const waterColor =
status === "maintenance"
? "#a16207" // gold-700
: status === "closed"
? "#86868b" // surface-400
: l > 0.85
? "#dc2626" // red — over threshold
: l < 0.15
? "#2563eb" // blue — under threshold
: "#0e7490"; // cyan-700 — flowing nicely
const headgateColor = status === "closed" ? "#3b3b3f" : "#1a4d2e";
return (
<svg
width={w}
height={h}
viewBox="0 0 32 40"
role="img"
aria-label={ariaLabel}
className={className}
style={{ display: "inline-block", flexShrink: 0 }}
>
{/* Top "headgate" — a small handled gate */}
<g stroke={headgateColor} strokeWidth={stroke} fill="none" strokeLinecap="round">
{/* Gate top bar */}
<path d="M9 4 H23" />
{/* Gate handle */}
<path d="M14 1.5 V4 M18 1.5 V4" />
{/* Gate frame */}
<path d="M8 4 V8 H24 V4" />
{/* Gate wheel */}
<circle cx="16" cy="3" r="1.2" fill={headgateColor} />
</g>
{/* The ditch / pipe */}
<g
stroke="#1a1a1a"
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 8 L8 36 L24 36 L24 8" />
{/* Tick marks at quarter / half / three-quarter */}
<path d="M8 17 H11 M8 24 H11 M8 31 H11" />
<path d="M24 17 H21 M24 24 H21 M24 31 H21" />
</g>
{/* Water level — clipped inside the pipe */}
<clipPath id={`wg-clip-${Math.round(size)}-${status}`}>
<path d="M9.2 9 L9.2 35.2 L22.8 35.2 L22.8 9 Z" />
</clipPath>
<g clipPath={`url(#wg-clip-${Math.round(size)}-${status})`}>
<rect
x={9.2}
y={35.2 - 25.6 * l}
width={13.6}
height={25.6 * l}
fill={waterColor}
opacity={0.85}
/>
{/* Wavy water surface — three short arcs */}
{l > 0 && (
<path
d={`M9.2 ${35.2 - 25.6 * l} q1.7 -1 3.4 0 t3.4 0 t3.4 0 t3.4 0`}
stroke={waterColor}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
)}
</g>
{/* Status dot — top-right corner */}
<circle
cx={26}
cy={3}
r={1.4}
fill={
status === "open"
? "#16a34a" // green
: status === "closed"
? "#9ca3af" // gray
: "#ca8a04" // gold
}
/>
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { WaterGauge } from "./WaterGauge";
export { SeasonMark } from "./SeasonMark";
+82
View File
@@ -0,0 +1,82 @@
/**
* Water Log — small audit + alert helpers.
*
* Every mutating action in `actions/water-log/*` calls `logAuditEvent()`
* so a regulator or curious admin can answer "who deleted that headgate
* on Tuesday" without trawling server logs.
*/
import "server-only";
import { query } from "@/lib/db";
import { withBrand } from "@/db/client";
export type WaterAuditEvent = {
brandId: string;
actorLabel: string;
actorId?: string | null;
action: string;
entityType: "headgate" | "user" | "entry" | "settings" | "session";
entityId?: string | null;
details?: Record<string, unknown> | null;
};
/**
* Fire-and-forget audit insert. The withBrand wrapper sets the brand
* context so RLS lets the write through. We swallow errors so a broken
* audit log never blocks the user-facing action — but we always log to
* the console as a backstop.
*/
export async function logAuditEvent(ev: WaterAuditEvent): Promise<void> {
try {
await withBrand(ev.brandId, async () => {
await query(
`INSERT INTO water_audit_log
(brand_id, actor_id, actor_label, action, entity_type, entity_id, details)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
ev.brandId,
ev.actorId ?? null,
ev.actorLabel,
ev.action,
ev.entityType,
ev.entityId ?? null,
ev.details ? JSON.stringify(ev.details) : null,
],
);
});
} catch (err) {
// Audit must not break the user flow. Surface to console.
console.error("[water-log] audit insert failed", err, ev);
}
}
/**
* Log a high/low water alert. Currently this is a passive record — the
* scheduled job that actually sends SMS/WhatsApp can pick rows where
* `sent_at IS NULL`. Phase 2 wires up the sender.
*/
export async function logAlert(args: {
brandId: string;
headgateId: string | null;
alertType: "high" | "low";
reading: number;
threshold: number;
headgateName: string;
}): Promise<void> {
try {
await withBrand(args.brandId, async () => {
await query(
`INSERT INTO water_alert_log
(brand_id, alert_type, headgate_id, message)
VALUES ($1, $2, $3, $4)`,
[
args.brandId,
args.alertType,
args.headgateId,
`${args.alertType.toUpperCase()} alert on ${args.headgateName}: reading ${args.reading} crossed threshold ${args.threshold}`,
],
);
});
} catch (err) {
console.error("[water-log] alert insert failed", err, args);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Water Log — PIN hashing.
*
* PINs in TYLER OS are 4-digit numerics that field workers type on a wet
* phone screen. That makes them both *very* low-entropy and *very*
* frequently entered. A slow KDF (scrypt) gives us a defense-in-depth
* layer: even if the database leaks, an attacker still has to do real
* work per guess.
*
* Format: `scrypt$N$r$p$salt_b64$hash_b64` — self-describing so we can
* upgrade the parameters later without forking the data. No third-party
* deps; Node's built-in `crypto.scryptSync` is plenty.
*/
import "server-only";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const N = 16384; // CPU/memory cost
const r = 8;
const p = 1;
const KEY_LEN = 32;
const SALT_LEN = 16;
export const PIN_MIN = 4;
export const PIN_MAX = 8;
/**
* Validate raw PIN format. Returns null on success, an error string on failure.
* Used by API entry points and unit tests alike.
*/
export function validatePin(rawPin: unknown): string | null {
if (typeof rawPin !== "string") return "PIN must be a string";
if (rawPin.length < PIN_MIN || rawPin.length > PIN_MAX) {
return `PIN must be ${PIN_MIN}${PIN_MAX} digits`;
}
if (!/^\d+$/.test(rawPin)) return "PIN must be numeric";
// Catch obviously weak PINs (1111, 1234, 0000, …). These are still
// possible to use, but we warn. We don't reject — the customer
// explicitly asked for simple PINs.
return null;
}
/** True if the PIN is "weak" (all-same, monotonic, or palindromic). */
export function isWeakPin(rawPin: string): boolean {
if (/^(.)\1+$/.test(rawPin)) return true; // 1111, 2222
if ("0123456789".includes(rawPin)) return true; // 1234, 5678
if ("9876543210".includes(rawPin)) return true; // 4321, 8765
if (rawPin === rawPin.split("").reverse().join("")) return true; // 1221
return false;
}
export function hashPin(rawPin: string): string {
const salt = randomBytes(SALT_LEN);
const hash = scryptSync(rawPin.normalize("NFKC"), salt, KEY_LEN, {
N,
r,
p,
});
return `scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString(
"base64",
)}`;
}
export function verifyPin(rawPin: string, stored: string): boolean {
if (typeof stored !== "string" || !stored.startsWith("scrypt$")) {
return false;
}
const parts = stored.split("$");
if (parts.length !== 6) return false;
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
const n = parseInt(nStr, 10);
const r = parseInt(rStr, 10);
const p = parseInt(pStr, 10);
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
return false;
}
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(saltB64, "base64");
expected = Buffer.from(hashB64, "base64");
} catch {
return false;
}
if (expected.length !== KEY_LEN) return false;
let candidate: Buffer;
try {
candidate = scryptSync(rawPin.normalize("NFKC"), salt, KEY_LEN, {
N: n,
r,
p,
});
} catch {
return false;
}
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
}
/** Generate a random 4-digit PIN. Used by the create-user action. */
export function generatePin(): string {
// Reject obviously weak PINs at generation time so the admin never has
// to hand a brand-new worker a PIN of "1234".
for (let attempt = 0; attempt < 16; attempt++) {
const candidate = (
Math.floor(Math.random() * 10000) + 0
)
.toString()
.padStart(4, "0");
if (!isWeakPin(candidate)) return candidate;
}
// Fallback: still return something — this branch is essentially impossible
return Math.floor(Math.random() * 10000)
.toString()
.padStart(4, "0");
}