refactor(water-log): replace PIN auth with self-contained cookie module
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.
This replaces the whole stack with a self-contained module:
src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)
- verifyAdminPin(pin) — scrypt verify, sets wl_admin_session
cookie to a random UUID
- getAdminSession() — read cookie, return session or null
- clearAdminSession() — delete cookie
- hashPin (re-exported) — same scrypt params as verifyPin
The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.
Deleted:
- verifyWaterAdminPin server action in settings.ts (no callers)
- requireWaterAdminSession / getWaterAdminSession from auth.ts
(replaced by cookie-only check)
- the cookie-outside-transaction dance (no longer needed)
Updated callers:
- /api/water-admin-auth — POST { pin } instead of { brandId, pin }
- /water/admin — uses new getAdminSession() helper
- /water/admin/login — no longer needs brandId prop
- /admin/water-log/entries|headgates|users/[id] — same helper
Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
This commit is contained in:
@@ -5,8 +5,8 @@
|
||||
* 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log`
|
||||
* and runs brand-scoped via `withBrand()`.
|
||||
* 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate
|
||||
* `water_admin_sessions` cookie; `requireWaterAdminSession()` is the
|
||||
* gate for those.
|
||||
* `wl_admin_session` cookie; `getAdminSession()` in
|
||||
* `@/lib/water-admin-pin-auth` is the gate for those.
|
||||
*
|
||||
* All actions return either `{ success: true, ... }` or
|
||||
* `{ success: false, error }`. We never throw across the action boundary
|
||||
@@ -108,10 +108,11 @@ export type AlertLogEntry = {
|
||||
|
||||
// ── Auth helpers ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// `requireWaterAdminPermission` and `requireWaterAdminSession` live in
|
||||
// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`;
|
||||
// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither
|
||||
// calls the platform-level `getSession()` from `@/lib/auth`.
|
||||
// `requireWaterAdminPermission` lives in `@/actions/water-log/auth`
|
||||
// (site-admin gate via `getAdminUser()`). The brand-admin PIN gate is
|
||||
// `getAdminSession()` in `@/lib/water-admin-pin-auth` (cookie-only,
|
||||
// no DB session, no Neon Auth). Neither calls the platform-level
|
||||
// `getSession()` from `@/lib/auth`.
|
||||
|
||||
// Global hint used by the field admin portal: water log is currently
|
||||
// scoped to the Tuxedo brand only. Surfacing this in one place makes it
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/**
|
||||
* Water Log — auth layer.
|
||||
*
|
||||
* Centralizes the three auth gates the water-log module uses:
|
||||
* Centralizes the auth gates the water-log module uses:
|
||||
*
|
||||
* 1. `requireFieldSession()` — read wl_session cookie, look up
|
||||
* the session row in `water_sessions`,
|
||||
* return user/brand/role. Gates every
|
||||
* irrigator PIN action (/water).
|
||||
* 2. `requireWaterAdminSession()` — read wl_admin_session cookie, look
|
||||
* up the row in `water_admin_sessions`.
|
||||
* Gates brand-admin PIN paths
|
||||
* (/water/admin/*).
|
||||
* 3. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
|
||||
* 2. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
|
||||
* admin_users.can_manage_water_log).
|
||||
* Gates /admin/water-log/*.
|
||||
*
|
||||
* **Crucially, the two PIN-only helpers (1) and (2) never call
|
||||
* `getSession()` from `@/lib/auth` and never call `getAdminUser()`.**
|
||||
* The water log module is intentionally PIN-based and self-contained;
|
||||
* a prior version of this code carried spurious `getSession()` calls
|
||||
* that hung PIN submission for users with no platform login. The static
|
||||
* "does not import getSession" test in `tests/unit/water-log-auth.test.ts`
|
||||
* guards against that regression.
|
||||
* **Crucially, the PIN-only helper (1) never calls `getSession()` from
|
||||
* `@/lib/auth` and never calls `getAdminUser()`.** The water log
|
||||
* module is intentionally PIN-based and self-contained; a prior
|
||||
* version of this code carried spurious `getSession()` calls that
|
||||
* hung PIN submission for users with no platform login. The static
|
||||
* "does not import getSession" test in
|
||||
* `tests/unit/water-log-auth.test.ts` guards against that regression.
|
||||
*
|
||||
* NOTE: Brand-admin PIN auth for `/water/admin/*` lives in
|
||||
* `src/lib/water-admin-pin-auth.ts` (cookie-only, no DB session,
|
||||
* no Neon Auth). It is intentionally a separate module so it can be
|
||||
* audited in isolation.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
@@ -31,7 +32,6 @@ import { withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
waterSessions,
|
||||
waterIrrigators,
|
||||
waterAdminSessions,
|
||||
} from "@/db/schema/water-log";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
@@ -41,17 +41,6 @@ export type FieldSession =
|
||||
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type AdminPinSession =
|
||||
| { ok: true; sessionId: string; brandId: string; adminUserId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type AdminPinSessionInfo = {
|
||||
sessionId: string;
|
||||
brandId: string;
|
||||
adminUserId: string;
|
||||
role: "water_admin";
|
||||
};
|
||||
|
||||
export type SiteAdminPermission =
|
||||
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
||||
| { ok: false; error: string };
|
||||
@@ -116,76 +105,6 @@ export async function requireFieldSession(): Promise<FieldSession> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-admin PIN session gate — /water/admin/*.
|
||||
*
|
||||
* Reads the `wl_admin_session` cookie, joins it to
|
||||
* `water_admin_sessions`, and returns brand/admin info. Same error
|
||||
* semantics as `requireFieldSession` but for the admin-PIN cookie.
|
||||
*/
|
||||
export async function requireWaterAdminSession(): Promise<AdminPinSession> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) return { ok: false, error: "Not signed in" };
|
||||
|
||||
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 { ok: false as const, error: "Session not found" };
|
||||
if (row.expiresAt.getTime() < Date.now()) {
|
||||
return { ok: false as const, error: "Session expired" };
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
sessionId: row.id,
|
||||
brandId: row.brandId,
|
||||
adminUserId: row.adminUserId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Return null on miss" variant of `requireWaterAdminSession`. Used by
|
||||
* pages that want to render conditionally based on whether the user
|
||||
* has a valid admin-PIN session, without forcing a hard error.
|
||||
*/
|
||||
export async function getWaterAdminSession(): Promise<AdminPinSessionInfo | 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current field session user (or null). Cheap, idempotent.
|
||||
* Used by the admin settings page to render the "logged in as" badge
|
||||
@@ -225,7 +144,7 @@ export async function getFieldSessionUser(): Promise<{
|
||||
* This is the only helper in this module that calls
|
||||
* `getAdminUser()`, and that's intentional: it backs the
|
||||
* `/admin/water-log/*` pages which sit behind Neon Auth in the
|
||||
* middleware. The two PIN-only helpers above deliberately do not.
|
||||
* middleware. The PIN-only helper above deliberately does not.
|
||||
*/
|
||||
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -240,4 +159,4 @@ export async function requireWaterAdminPermission(): Promise<SiteAdminPermission
|
||||
// the success-branch type narrow (non-nullable) so callers can read
|
||||
// `auth.adminUser.foo` without an extra null check.
|
||||
return { ok: true, adminUser: adminUser as NonNullable<typeof adminUser> };
|
||||
}
|
||||
}
|
||||
@@ -302,20 +302,11 @@ export async function logoutWater(): Promise<void> {
|
||||
}
|
||||
|
||||
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");
|
||||
// The `wl_admin_session` cookie is now self-contained — see
|
||||
// `src/lib/water-admin-pin-auth.ts`. No DB row to clean up; just
|
||||
// delete the cookie.
|
||||
const { clearAdminSession } = await import("@/lib/water-admin-pin-auth");
|
||||
await clearAdminSession();
|
||||
}
|
||||
|
||||
export async function setWaterLang(lang: string): Promise<void> {
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
*
|
||||
* 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`.
|
||||
* `water_admin_settings` (one row per brand).
|
||||
*
|
||||
* 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`.
|
||||
* PIN verification lives in `src/lib/water-admin-pin-auth.ts` (no
|
||||
* server actions, no Neon Auth). This file only handles the
|
||||
* platform-admin-gated `/admin/water-log/settings` UI which sets /
|
||||
* updates the PIN and other admin settings.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
@@ -22,13 +20,9 @@ import {
|
||||
type WaterAdminSettings,
|
||||
} from "@/db/schema/water-log";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
||||
import { hashPin, generatePin } from "@/lib/water-log-pin";
|
||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||
|
||||
// Note: we deliberately do NOT import `getSession` from `@/lib/auth`.
|
||||
// The water-admin PIN entry flow (`verifyWaterAdminPin`) is PIN-based
|
||||
// and self-contained — see docs/superpowers/specs/2026-07-01-water-log-no-platform-login-design.md.
|
||||
|
||||
export type AdminSettings = {
|
||||
enabled: boolean;
|
||||
sessionDurationHours: number;
|
||||
@@ -192,92 +186,3 @@ const adminUser = await getAdminUser();
|
||||
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 };
|
||||
|
||||
// Run the DB work inside withBrand, but resolve the cookie write
|
||||
// OUTSIDE the transaction. Calling `cookies().set(...)` from inside
|
||||
// a pg transaction corrupts Next.js's request-scoped AsyncLocalStorage
|
||||
// and the cookie set throws for users without a platform login
|
||||
// (manifesting as a 500 from /api/water-admin-auth).
|
||||
const result = await 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" } as const;
|
||||
}
|
||||
if (!settings.enabled) {
|
||||
return { success: false, error: "Admin portal is disabled" } as const;
|
||||
}
|
||||
if (!settings.pinHash) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"No PIN configured — generate one in /admin/water-log/settings",
|
||||
} as const;
|
||||
}
|
||||
if (!verifyPin(pin, settings.pinHash)) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: false, error: "Invalid PIN" } as const;
|
||||
}
|
||||
|
||||
// The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only.
|
||||
// There is no platform-admin requirement here — `wl_admin_session` is
|
||||
// valid whether or not a Neon Auth session exists. We deliberately do
|
||||
// NOT call `getAdminUser()` (it would touch cookies via Neon Auth and
|
||||
// 500 for users without a platform login). `adminUserId` is therefore
|
||||
// always the zero-UUID placeholder; audit attribution happens elsewhere.
|
||||
const PLACEHOLDER_ADMIN_USER_ID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
||||
);
|
||||
const [session] = await db
|
||||
.insert(waterAdminSessions)
|
||||
.values({
|
||||
brandId,
|
||||
adminUserId: PLACEHOLDER_ADMIN_USER_ID,
|
||||
pinHashUsed: settings.pinHash,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterAdminSessions.id });
|
||||
if (!session) {
|
||||
return { success: false, error: "Failed to create session" } as const;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
sessionId: session.id,
|
||||
sessionDurationHours: settings.sessionDurationHours,
|
||||
};
|
||||
});
|
||||
|
||||
// Cookie write happens AFTER withBrand resolves, in the outer request
|
||||
// frame where Next.js's cookie store is intact. See the comment above.
|
||||
if (result.success) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_admin_session", result.sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: result.sessionDurationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
return { success: true, session_id: result.sessionId };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/auth";
|
||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||
import { getWaterEntryById } from "@/actions/water-log/admin";
|
||||
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
|
||||
import Link from "next/link";
|
||||
@@ -26,7 +26,7 @@ export default async function WaterLogEntryPage({ params, searchParams }: EntryP
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
const isWaterAdmin = waterSession !== null;
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/auth";
|
||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||
import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
|
||||
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
|
||||
import Link from "next/link";
|
||||
@@ -27,7 +27,7 @@ export default async function WaterLogHeadgatePage({ params, searchParams }: Hea
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
const isWaterAdmin = waterSession !== null;
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/auth";
|
||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||
import { getWaterIrrigators } from "@/actions/water-log/admin";
|
||||
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
|
||||
import Link from "next/link";
|
||||
@@ -27,7 +27,7 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
const isWaterAdmin = waterSession !== null;
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
/**
|
||||
* POST /api/water-admin-auth
|
||||
*
|
||||
* Body: { brandId: string, pin: string }
|
||||
* Body: { 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.
|
||||
* Used by the mobile/PIN-only `/water/admin/login` portal. Brand is
|
||||
* hardcoded (Tuxedo-only — see CLAUDE.md "Public Storefront
|
||||
* Architecture"). All auth logic lives in
|
||||
* `src/lib/water-admin-pin-auth.ts`.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
|
||||
import { verifyAdminPin } from "@/lib/water-admin-pin-auth";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { brandId, pin } = (await request.json()) as {
|
||||
brandId?: string;
|
||||
pin?: string;
|
||||
};
|
||||
if (!brandId || !pin) {
|
||||
const { pin } = (await request.json()) as { pin?: string };
|
||||
if (!pin) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Missing brandId or pin" },
|
||||
{ success: false, error: "Missing pin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const result = await verifyWaterAdminPin(brandId, pin);
|
||||
if (!result.success) {
|
||||
const result = await verifyAdminPin(pin);
|
||||
if (!result.ok) {
|
||||
// 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;
|
||||
@@ -43,4 +40,4 @@ export async function POST(request: Request) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = { brandId: string };
|
||||
|
||||
export default function WaterAdminPinClient({ brandId }: Props) {
|
||||
export default function WaterAdminPinClient() {
|
||||
const router = useRouter();
|
||||
const [pin, setPin] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -21,7 +19,7 @@ export default function WaterAdminPinClient({ brandId }: Props) {
|
||||
const res = await fetch("/api/water-admin-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, pin }),
|
||||
body: JSON.stringify({ pin }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import WaterAdminPinClient from "./WaterAdminPinClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,15 +8,13 @@ export const dynamic = "force-dynamic";
|
||||
* The 4-digit admin PIN gates `/water/admin/*` for the Tuxedo brand.
|
||||
* Users must be able to enter the PIN WITHOUT being signed into the
|
||||
* platform — this is a PIN-only entry point for brand staff in the
|
||||
* field. The corresponding `verifyWaterAdminPin()` action deliberately
|
||||
* does not call Neon Auth (no `getSession()` / no `getAdminUser()`).
|
||||
* field. The auth itself lives in `src/lib/water-admin-pin-auth.ts`,
|
||||
* which is self-contained (no Neon Auth).
|
||||
*
|
||||
* If you need a similar portal for another brand, duplicate this
|
||||
* page under a different slug (e.g. `/water/admin-tuxedo/login`) and
|
||||
* swap in that brand's UUID.
|
||||
* update the brand UUID in `water-admin-pin-auth.ts`.
|
||||
*/
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default async function WaterAdminLoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex flex-col items-center justify-center p-6">
|
||||
@@ -26,7 +23,7 @@ export default async function WaterAdminLoginPage() {
|
||||
<h1 className="text-2xl font-bold text-slate-900">Water Log Admin</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Enter your 4-digit admin PIN</p>
|
||||
</div>
|
||||
<WaterAdminPinClient brandId={TUXEDO_BRAND_ID} />
|
||||
<WaterAdminPinClient />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
getWaterDisplaySummary,
|
||||
getWaterHeadgatesAdmin,
|
||||
getWaterIrrigators,
|
||||
getWaterAlertLog,
|
||||
} from "@/actions/water-log/admin";
|
||||
import { getAdminSession, TUXEDO_BRAND_ID } from "@/lib/water-admin-pin-auth";
|
||||
import WaterAdminClient from "@/components/water/WaterAdminClient";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function WaterAdminPage() {
|
||||
const cookieStore = await cookies();
|
||||
const adminSession = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!adminSession) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
redirect("/water/admin/login");
|
||||
}
|
||||
|
||||
@@ -37,4 +34,4 @@ export default async function WaterAdminPage() {
|
||||
initialAlertLog={alertLog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Water Log — Admin PIN auth.
|
||||
*
|
||||
* Self-contained, PIN-only authentication for the `/water/admin/*`
|
||||
* portal. **Has no Neon Auth dependency** — no `getSession()`, no
|
||||
* `getAdminUser()`, no cookies read/written by anything other than
|
||||
* this module.
|
||||
*
|
||||
* Design:
|
||||
* - The `wl_admin_session` cookie is an opaque bearer token (a random
|
||||
* 128-bit ID). Cookie presence = authenticated. Cookie absence =
|
||||
* redirect to /water/admin/login.
|
||||
* - No DB session table is written. The cookie expiry (`maxAge`) is
|
||||
* the source of truth for session lifetime.
|
||||
* - The Tuxedo brand UUID is hardcoded here because this portal is
|
||||
* Tuxedo-specific (see CLAUDE.md "Public Storefront Architecture"
|
||||
* — the same hardcoded-brand pattern is used in
|
||||
* `src/app/water/admin/page.tsx`).
|
||||
*
|
||||
* Used by:
|
||||
* - `POST /api/water-admin-auth` (login)
|
||||
* - `src/app/water/admin/page.tsx` (gate)
|
||||
* - `src/app/admin/water-log/entries/...` (gate, "isWaterAdmin" branch)
|
||||
* - `logoutWaterAdmin` in field.ts (sign-out)
|
||||
*/
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { waterAdminSettings } from "@/db/schema/water-log";
|
||||
import { hashPin, verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||
|
||||
/** Tuxedo brand UUID — hardcoded because this portal is Tuxedo-only. */
|
||||
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
/** Session lifetime, in hours, read from `water_admin_settings.session_duration_hours`. */
|
||||
const DEFAULT_SESSION_HOURS = 8;
|
||||
|
||||
/** Result of a successful PIN verification. */
|
||||
export type AdminSession = {
|
||||
sessionId: string;
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify the admin PIN against the DB-stored scrypt hash. On success,
|
||||
* sets the `wl_admin_session` cookie to a random opaque bearer token
|
||||
* with the configured session duration. Returns `{ ok: false, error }`
|
||||
* for any failure (validation, missing settings, hash mismatch).
|
||||
*/
|
||||
export async function verifyAdminPin(
|
||||
pin: string,
|
||||
): Promise<{ ok: true; session: AdminSession } | { ok: false; error: string }> {
|
||||
const formatError = validatePin(pin);
|
||||
if (formatError) return { ok: false, error: formatError };
|
||||
|
||||
const sessionId = randomUUID();
|
||||
let sessionDurationHours = DEFAULT_SESSION_HOURS;
|
||||
|
||||
const result = await withBrand(TUXEDO_BRAND_ID, async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
enabled: waterAdminSettings.enabled,
|
||||
pinHash: waterAdminSettings.pinHash,
|
||||
sessionDurationHours: waterAdminSettings.sessionDurationHours,
|
||||
})
|
||||
.from(waterAdminSettings)
|
||||
.where(eq(waterAdminSettings.brandId, TUXEDO_BRAND_ID))
|
||||
.limit(1);
|
||||
const settings = rows[0];
|
||||
if (!settings) return { ok: false as const, error: "Admin portal not configured" };
|
||||
if (!settings.enabled) return { ok: false as const, error: "Admin portal is disabled" };
|
||||
if (!settings.pinHash) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: "No PIN configured — generate one in /admin/water-log/settings",
|
||||
};
|
||||
}
|
||||
if (!verifyPin(pin, settings.pinHash)) {
|
||||
// Constant-time-ish delay to make online brute-force slightly less attractive.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { ok: false as const, error: "Invalid PIN" };
|
||||
}
|
||||
sessionDurationHours = settings.sessionDurationHours ?? DEFAULT_SESSION_HOURS;
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
if (!result.ok) return result;
|
||||
|
||||
// Set the cookie OUTSIDE the DB transaction (cookies().set()
|
||||
// corrupts Next.js's request-scoped AsyncLocalStorage if called from
|
||||
// inside a pg transaction).
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_admin_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: sessionDurationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
return { ok: true, session: { sessionId, brandId: TUXEDO_BRAND_ID } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current admin session from the cookie. Returns `null` if
|
||||
* the cookie is missing or empty. Does NOT validate against a DB
|
||||
* session table — the cookie itself is the session.
|
||||
*/
|
||||
export async function getAdminSession(): Promise<AdminSession | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) return null;
|
||||
return { sessionId, brandId: TUXEDO_BRAND_ID };
|
||||
}
|
||||
|
||||
/** Clear the admin session cookie. Idempotent. */
|
||||
export async function clearAdminSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete("wl_admin_session");
|
||||
}
|
||||
|
||||
// Re-export `hashPin` so callers (e.g. a future "set PIN" UI) can
|
||||
// produce hashes with the same scrypt params as `verifyPin` checks
|
||||
// against.
|
||||
export { hashPin };
|
||||
Reference in New Issue
Block a user