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`
|
* 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log`
|
||||||
* and runs brand-scoped via `withBrand()`.
|
* and runs brand-scoped via `withBrand()`.
|
||||||
* 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate
|
* 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate
|
||||||
* `water_admin_sessions` cookie; `requireWaterAdminSession()` is the
|
* `wl_admin_session` cookie; `getAdminSession()` in
|
||||||
* gate for those.
|
* `@/lib/water-admin-pin-auth` is the gate for those.
|
||||||
*
|
*
|
||||||
* All actions return either `{ success: true, ... }` or
|
* All actions return either `{ success: true, ... }` or
|
||||||
* `{ success: false, error }`. We never throw across the action boundary
|
* `{ success: false, error }`. We never throw across the action boundary
|
||||||
@@ -108,10 +108,11 @@ export type AlertLogEntry = {
|
|||||||
|
|
||||||
// ── Auth helpers ───────────────────────────────────────────────────────────
|
// ── Auth helpers ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// `requireWaterAdminPermission` and `requireWaterAdminSession` live in
|
// `requireWaterAdminPermission` lives in `@/actions/water-log/auth`
|
||||||
// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`;
|
// (site-admin gate via `getAdminUser()`). The brand-admin PIN gate is
|
||||||
// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither
|
// `getAdminSession()` in `@/lib/water-admin-pin-auth` (cookie-only,
|
||||||
// calls the platform-level `getSession()` from `@/lib/auth`.
|
// 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
|
// 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
|
// scoped to the Tuxedo brand only. Surfacing this in one place makes it
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Water Log — auth layer.
|
* 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
|
* 1. `requireFieldSession()` — read wl_session cookie, look up
|
||||||
* the session row in `water_sessions`,
|
* the session row in `water_sessions`,
|
||||||
* return user/brand/role. Gates every
|
* return user/brand/role. Gates every
|
||||||
* irrigator PIN action (/water).
|
* irrigator PIN action (/water).
|
||||||
* 2. `requireWaterAdminSession()` — read wl_admin_session cookie, look
|
* 2. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
|
||||||
* up the row in `water_admin_sessions`.
|
|
||||||
* Gates brand-admin PIN paths
|
|
||||||
* (/water/admin/*).
|
|
||||||
* 3. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
|
|
||||||
* admin_users.can_manage_water_log).
|
* admin_users.can_manage_water_log).
|
||||||
* Gates /admin/water-log/*.
|
* Gates /admin/water-log/*.
|
||||||
*
|
*
|
||||||
* **Crucially, the two PIN-only helpers (1) and (2) never call
|
* **Crucially, the PIN-only helper (1) never calls `getSession()` from
|
||||||
* `getSession()` from `@/lib/auth` and never call `getAdminUser()`.**
|
* `@/lib/auth` and never calls `getAdminUser()`.** The water log
|
||||||
* The water log module is intentionally PIN-based and self-contained;
|
* module is intentionally PIN-based and self-contained; a prior
|
||||||
* a prior version of this code carried spurious `getSession()` calls
|
* version of this code carried spurious `getSession()` calls that
|
||||||
* that hung PIN submission for users with no platform login. The static
|
* hung PIN submission for users with no platform login. The static
|
||||||
* "does not import getSession" test in `tests/unit/water-log-auth.test.ts`
|
* "does not import getSession" test in
|
||||||
* guards against that regression.
|
* `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";
|
"use server";
|
||||||
|
|
||||||
@@ -31,7 +32,6 @@ import { withPlatformAdmin } from "@/db/client";
|
|||||||
import {
|
import {
|
||||||
waterSessions,
|
waterSessions,
|
||||||
waterIrrigators,
|
waterIrrigators,
|
||||||
waterAdminSessions,
|
|
||||||
} from "@/db/schema/water-log";
|
} from "@/db/schema/water-log";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
|
||||||
@@ -41,17 +41,6 @@ export type FieldSession =
|
|||||||
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||||
| { ok: false; error: string };
|
| { 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 =
|
export type SiteAdminPermission =
|
||||||
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
||||||
| { ok: false; error: string };
|
| { 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.
|
* Read the current field session user (or null). Cheap, idempotent.
|
||||||
* Used by the admin settings page to render the "logged in as" badge
|
* 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
|
* This is the only helper in this module that calls
|
||||||
* `getAdminUser()`, and that's intentional: it backs the
|
* `getAdminUser()`, and that's intentional: it backs the
|
||||||
* `/admin/water-log/*` pages which sit behind Neon Auth in 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> {
|
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> {
|
||||||
const adminUser = await getAdminUser();
|
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
|
// the success-branch type narrow (non-nullable) so callers can read
|
||||||
// `auth.adminUser.foo` without an extra null check.
|
// `auth.adminUser.foo` without an extra null check.
|
||||||
return { ok: true, adminUser: adminUser as NonNullable<typeof adminUser> };
|
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> {
|
export async function logoutWaterAdmin(): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
// The `wl_admin_session` cookie is now self-contained — see
|
||||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
// `src/lib/water-admin-pin-auth.ts`. No DB row to clean up; just
|
||||||
if (sessionId) {
|
// delete the cookie.
|
||||||
try {
|
const { clearAdminSession } = await import("@/lib/water-admin-pin-auth");
|
||||||
await withPlatformAdmin(async (db) => {
|
await clearAdminSession();
|
||||||
await db
|
|
||||||
.delete(waterAdminSessions)
|
|
||||||
.where(eq(waterAdminSessions.id, sessionId));
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// ignore — cookie is being deleted anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cookieStore.delete("wl_admin_session");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWaterLang(lang: string): Promise<void> {
|
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
|
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
|
||||||
* from the irrigators' PIN). That PIN is *hashed* and stored in
|
* from the irrigators' PIN). That PIN is *hashed* and stored in
|
||||||
* `water_admin_settings` (one row per brand). Sessions are tracked in
|
* `water_admin_settings` (one row per brand).
|
||||||
* `water_admin_sessions`.
|
|
||||||
*
|
*
|
||||||
* There is currently no per-admin-user PIN — a single brand-wide admin
|
* PIN verification lives in `src/lib/water-admin-pin-auth.ts` (no
|
||||||
* PIN. If you need per-user PINs (e.g. for an audit trail of which
|
* server actions, no Neon Auth). This file only handles the
|
||||||
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
|
* platform-admin-gated `/admin/water-log/settings` UI which sets /
|
||||||
* key sessions by `admin_user_id`.
|
* updates the PIN and other admin settings.
|
||||||
*/
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import {
|
import {
|
||||||
@@ -22,13 +20,9 @@ import {
|
|||||||
type WaterAdminSettings,
|
type WaterAdminSettings,
|
||||||
} from "@/db/schema/water-log";
|
} from "@/db/schema/water-log";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
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";
|
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 = {
|
export type AdminSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sessionDurationHours: number;
|
sessionDurationHours: number;
|
||||||
@@ -192,92 +186,3 @@ const adminUser = await getAdminUser();
|
|||||||
return { success: true, pin };
|
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 { 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 { getWaterEntryById } from "@/actions/water-log/admin";
|
||||||
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
|
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -26,7 +26,7 @@ export default async function WaterLogEntryPage({ params, searchParams }: EntryP
|
|||||||
(adminUser?.role === "brand_admin" &&
|
(adminUser?.role === "brand_admin" &&
|
||||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||||
adminUser?.can_manage_water_log);
|
adminUser?.can_manage_water_log);
|
||||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
const isWaterAdmin = waterSession !== null;
|
||||||
|
|
||||||
if (!isSiteAdmin && !isWaterAdmin) {
|
if (!isSiteAdmin && !isWaterAdmin) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { redirect } from "next/navigation";
|
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 { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
|
||||||
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
|
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -27,7 +27,7 @@ export default async function WaterLogHeadgatePage({ params, searchParams }: Hea
|
|||||||
(adminUser?.role === "brand_admin" &&
|
(adminUser?.role === "brand_admin" &&
|
||||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||||
adminUser?.can_manage_water_log);
|
adminUser?.can_manage_water_log);
|
||||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
const isWaterAdmin = waterSession !== null;
|
||||||
|
|
||||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { redirect } from "next/navigation";
|
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 { getWaterIrrigators } from "@/actions/water-log/admin";
|
||||||
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
|
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -27,7 +27,7 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
|
|||||||
(adminUser?.role === "brand_admin" &&
|
(adminUser?.role === "brand_admin" &&
|
||||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||||
adminUser?.can_manage_water_log);
|
adminUser?.can_manage_water_log);
|
||||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
const isWaterAdmin = waterSession !== null;
|
||||||
|
|
||||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/water-admin-auth
|
* POST /api/water-admin-auth
|
||||||
*
|
*
|
||||||
* Body: { brandId: string, pin: string }
|
* Body: { pin: string }
|
||||||
* Side effect: sets the `wl_admin_session` cookie on success.
|
* Side effect: sets the `wl_admin_session` cookie on success.
|
||||||
*
|
*
|
||||||
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
|
* Used by the mobile/PIN-only `/water/admin/login` portal. Brand is
|
||||||
* itself is created by `verifyWaterAdminPin()` in
|
* hardcoded (Tuxedo-only — see CLAUDE.md "Public Storefront
|
||||||
* `src/actions/water-log/settings.ts`, which is the single source of
|
* Architecture"). All auth logic lives in
|
||||||
* truth for admin PIN verification.
|
* `src/lib/water-admin-pin-auth.ts`.
|
||||||
*/
|
*/
|
||||||
import { NextResponse } from "next/server";
|
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";
|
import { captureError } from "@/lib/sentry";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { brandId, pin } = (await request.json()) as {
|
const { pin } = (await request.json()) as { pin?: string };
|
||||||
brandId?: string;
|
if (!pin) {
|
||||||
pin?: string;
|
|
||||||
};
|
|
||||||
if (!brandId || !pin) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Missing brandId or pin" },
|
{ success: false, error: "Missing pin" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await verifyWaterAdminPin(brandId, pin);
|
const result = await verifyAdminPin(pin);
|
||||||
if (!result.success) {
|
if (!result.ok) {
|
||||||
// Don't leak whether the PIN was wrong vs. not configured — both
|
// Don't leak whether the PIN was wrong vs. not configured — both
|
||||||
// are the same to a field attacker probing the endpoint.
|
// are the same to a field attacker probing the endpoint.
|
||||||
const status = result.error === "Invalid PIN" ? 401 : 403;
|
const status = result.error === "Invalid PIN" ? 401 : 403;
|
||||||
@@ -43,4 +40,4 @@ export async function POST(request: Request) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Props = { brandId: string };
|
export default function WaterAdminPinClient() {
|
||||||
|
|
||||||
export default function WaterAdminPinClient({ brandId }: Props) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pin, setPin] = useState("");
|
const [pin, setPin] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -21,7 +19,7 @@ export default function WaterAdminPinClient({ brandId }: Props) {
|
|||||||
const res = await fetch("/api/water-admin-auth", {
|
const res = await fetch("/api/water-admin-auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ brandId, pin }),
|
body: JSON.stringify({ pin }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import WaterAdminPinClient from "./WaterAdminPinClient";
|
import WaterAdminPinClient from "./WaterAdminPinClient";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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.
|
* 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
|
* 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
|
* platform — this is a PIN-only entry point for brand staff in the
|
||||||
* field. The corresponding `verifyWaterAdminPin()` action deliberately
|
* field. The auth itself lives in `src/lib/water-admin-pin-auth.ts`,
|
||||||
* does not call Neon Auth (no `getSession()` / no `getAdminUser()`).
|
* which is self-contained (no Neon Auth).
|
||||||
*
|
*
|
||||||
* If you need a similar portal for another brand, duplicate this
|
* If you need a similar portal for another brand, duplicate this
|
||||||
* page under a different slug (e.g. `/water/admin-tuxedo/login`) and
|
* 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() {
|
export default async function WaterAdminLoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-100 flex flex-col items-center justify-center p-6">
|
<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>
|
<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>
|
<p className="text-sm text-slate-500 mt-1">Enter your 4-digit admin PIN</p>
|
||||||
</div>
|
</div>
|
||||||
<WaterAdminPinClient brandId={TUXEDO_BRAND_ID} />
|
<WaterAdminPinClient />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import {
|
import {
|
||||||
getWaterDisplaySummary,
|
getWaterDisplaySummary,
|
||||||
getWaterHeadgatesAdmin,
|
getWaterHeadgatesAdmin,
|
||||||
getWaterIrrigators,
|
getWaterIrrigators,
|
||||||
getWaterAlertLog,
|
getWaterAlertLog,
|
||||||
} from "@/actions/water-log/admin";
|
} from "@/actions/water-log/admin";
|
||||||
|
import { getAdminSession, TUXEDO_BRAND_ID } from "@/lib/water-admin-pin-auth";
|
||||||
import WaterAdminClient from "@/components/water/WaterAdminClient";
|
import WaterAdminClient from "@/components/water/WaterAdminClient";
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function WaterAdminPage() {
|
export default async function WaterAdminPage() {
|
||||||
const cookieStore = await cookies();
|
const session = await getAdminSession();
|
||||||
const adminSession = cookieStore.get("wl_admin_session")?.value;
|
if (!session) {
|
||||||
if (!adminSession) {
|
|
||||||
redirect("/water/admin/login");
|
redirect("/water/admin/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,4 +34,4 @@ export default async function WaterAdminPage() {
|
|||||||
initialAlertLog={alertLog}
|
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 };
|
||||||
+150
-248
@@ -1,16 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for `src/actions/water-log/auth.ts` — the centralized auth
|
* Unit tests for the water-log auth layer.
|
||||||
* helpers for the water log module.
|
|
||||||
*
|
*
|
||||||
* These helpers are the gate for every field/admin-PIN server action
|
* Two modules are exercised here:
|
||||||
* (`/water`, `/water/admin/*`). Critically, **they must not call
|
|
||||||
* `getSession()` from `@/lib/auth`** (the Neon Auth wrapper) — that
|
|
||||||
* was the source of the bug where PIN submission hung for users with
|
|
||||||
* no platform login.
|
|
||||||
*
|
*
|
||||||
* If a future refactor accidentally re-introduces a `getSession()` call
|
* - `src/actions/water-log/auth.ts`
|
||||||
* in this module, the `does not import getSession from @/lib/auth` test
|
* The platform-side auth helpers (`requireFieldSession`,
|
||||||
* below will fail.
|
* `getFieldSessionUser`, `requireWaterAdminPermission`). These
|
||||||
|
* are used by `/water` (irrigator PIN) and `/admin/water-log/*`
|
||||||
|
* (Neon Auth + admin_users gate).
|
||||||
|
*
|
||||||
|
* - `src/lib/water-admin-pin-auth.ts`
|
||||||
|
* The brand-side PIN-only auth for `/water/admin/*`. Cookie
|
||||||
|
* presence = authenticated, no DB session table, no Neon Auth.
|
||||||
|
*
|
||||||
|
* Both modules are tested for the property that they never call
|
||||||
|
* `getSession()` from `@/lib/auth`. The static check at the top of
|
||||||
|
* the file enforces this on the platform-side module; the
|
||||||
|
* brand-side module has no `@/lib/auth` import at all by design.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
@@ -20,19 +26,24 @@ const state = vi.hoisted(() => ({
|
|||||||
cookieValue: undefined as string | undefined,
|
cookieValue: undefined as string | undefined,
|
||||||
mockDb: { select: vi.fn() } as { select: ReturnType<typeof vi.fn> },
|
mockDb: { select: vi.fn() } as { select: ReturnType<typeof vi.fn> },
|
||||||
mockWithPlatformAdmin: null as unknown as ReturnType<typeof vi.fn>,
|
mockWithPlatformAdmin: null as unknown as ReturnType<typeof vi.fn>,
|
||||||
|
mockWithBrand: null as unknown as ReturnType<typeof vi.fn>,
|
||||||
mockGetAdminUser: null as unknown as ReturnType<typeof vi.fn>,
|
mockGetAdminUser: null as unknown as ReturnType<typeof vi.fn>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
state.mockWithPlatformAdmin = vi.fn(
|
state.mockWithPlatformAdmin = vi.fn(
|
||||||
async (fn: (db: typeof state.mockDb) => Promise<unknown>) => fn(state.mockDb),
|
async (fn: (db: typeof state.mockDb) => Promise<unknown>) => fn(state.mockDb),
|
||||||
);
|
);
|
||||||
|
state.mockWithBrand = vi.fn(
|
||||||
|
async (_brandId: string, fn: (db: typeof state.mockDb) => Promise<unknown>) =>
|
||||||
|
fn(state.mockDb),
|
||||||
|
);
|
||||||
state.mockGetAdminUser = vi.fn();
|
state.mockGetAdminUser = vi.fn();
|
||||||
|
|
||||||
vi.mock("server-only", () => ({}));
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
vi.mock("@/db/client", () => ({
|
vi.mock("@/db/client", () => ({
|
||||||
withPlatformAdmin: state.mockWithPlatformAdmin,
|
withPlatformAdmin: state.mockWithPlatformAdmin,
|
||||||
withBrand: vi.fn(),
|
withBrand: state.mockWithBrand,
|
||||||
withDb: vi.fn(),
|
withDb: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -47,6 +58,8 @@ vi.mock("next/headers", () => ({
|
|||||||
state.cookieValue !== undefined
|
state.cookieValue !== undefined
|
||||||
? { value: state.cookieValue }
|
? { value: state.cookieValue }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
}),
|
}),
|
||||||
headers: () => Promise.resolve(new Headers()),
|
headers: () => Promise.resolve(new Headers()),
|
||||||
}));
|
}));
|
||||||
@@ -55,6 +68,7 @@ beforeEach(() => {
|
|||||||
state.cookieValue = undefined;
|
state.cookieValue = undefined;
|
||||||
state.mockDb.select.mockReset();
|
state.mockDb.select.mockReset();
|
||||||
state.mockWithPlatformAdmin.mockClear();
|
state.mockWithPlatformAdmin.mockClear();
|
||||||
|
state.mockWithBrand.mockClear();
|
||||||
state.mockGetAdminUser.mockReset();
|
state.mockGetAdminUser.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,9 +76,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("water-log/auth — module surface", () => {
|
describe("water-log/auth — module surface", () => {
|
||||||
it("does not import getSession from @/lib/auth", async () => {
|
it("does not import getSession from @/lib/auth", async () => {
|
||||||
// Strip comments (block + line) and collapse whitespace before checking
|
|
||||||
// — the JSDoc deliberately references these symbols as documentation
|
|
||||||
// of what the module does NOT do.
|
|
||||||
const fs = await import("node:fs/promises");
|
const fs = await import("node:fs/promises");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
const raw = await fs.readFile(
|
const raw = await fs.readFile(
|
||||||
@@ -80,114 +91,21 @@ describe("water-log/auth — module surface", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Module guard for the Tuxedo admin-PIN entry flow ───────────────────
|
describe("water-admin-pin-auth — module surface", () => {
|
||||||
//
|
it("does not import getSession from @/lib/auth", async () => {
|
||||||
// The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only.
|
|
||||||
// Users must be able to enter the admin PIN WITHOUT being signed into
|
|
||||||
// the platform. If a future refactor re-adds a Neon Auth dependency
|
|
||||||
// (getSession / getAdminUser) inside `verifyWaterAdminPin`, the PIN
|
|
||||||
// submission 500s for unauthenticated field staff.
|
|
||||||
//
|
|
||||||
// Note: other functions in settings.ts (getWaterAdminSettings,
|
|
||||||
// saveWaterAdminSettings, regenerateAdminPin) are called from the
|
|
||||||
// platform-admin-gated `/admin/water-log/settings/*` UI and correctly
|
|
||||||
// DO depend on `getAdminUser()`. Only `verifyWaterAdminPin` is the
|
|
||||||
// PIN-only entry point.
|
|
||||||
|
|
||||||
describe("water-log/settings — verifyWaterAdminPin surface", () => {
|
|
||||||
/**
|
|
||||||
* Extract just the body of `verifyWaterAdminPin` from settings.ts so
|
|
||||||
* the regression check is scoped to that function (and not falsely
|
|
||||||
* tripped by sibling functions that legitimately use getAdminUser).
|
|
||||||
*
|
|
||||||
* The function is the LAST export in the file, so we read from
|
|
||||||
* `export async function verifyWaterAdminPin` to EOF.
|
|
||||||
*/
|
|
||||||
async function getVerifyWaterAdminPinSource(): Promise<string> {
|
|
||||||
const fs = await import("node:fs/promises");
|
const fs = await import("node:fs/promises");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
const raw = await fs.readFile(
|
const raw = await fs.readFile(
|
||||||
path.resolve(
|
path.resolve(process.cwd(), "src/lib/water-admin-pin-auth.ts"),
|
||||||
process.cwd(),
|
|
||||||
"src/actions/water-log/settings.ts",
|
|
||||||
),
|
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const idx = raw.indexOf("export async function verifyWaterAdminPin");
|
const stripped = raw
|
||||||
if (idx === -1) throw new Error("verifyWaterAdminPin not found");
|
|
||||||
return raw.slice(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripComments(src: string): string {
|
|
||||||
return src
|
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
.replace(/^\s*\/\/.*$/gm, "")
|
.replace(/^\s*\/\/.*$/gm, "")
|
||||||
.replace(/\s+/g, " ");
|
.replace(/\s+/g, " ");
|
||||||
}
|
expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/);
|
||||||
|
|
||||||
it("does not call getAdminUser inside verifyWaterAdminPin", async () => {
|
|
||||||
const stripped = stripComments(await getVerifyWaterAdminPinSource());
|
|
||||||
expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call getSession inside verifyWaterAdminPin", async () => {
|
|
||||||
const stripped = stripComments(await getVerifyWaterAdminPinSource());
|
|
||||||
expect(stripped).not.toMatch(/\bgetSession\s*\(/);
|
expect(stripped).not.toMatch(/\bgetSession\s*\(/);
|
||||||
});
|
expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/);
|
||||||
|
|
||||||
it("always inserts the zero-UUID fallback adminUserId", async () => {
|
|
||||||
const stripped = stripComments(await getVerifyWaterAdminPinSource());
|
|
||||||
// Confirms the session row is inserted with the placeholder UUID
|
|
||||||
// — i.e. the function no longer conditions adminUserId on a
|
|
||||||
// successful platform-admin lookup.
|
|
||||||
expect(stripped).toMatch(
|
|
||||||
/00000000-0000-0000-0000-000000000000/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the wl_admin_session cookie OUTSIDE the withBrand callback", async () => {
|
|
||||||
// Regression: calling `cookies().set(...)` from inside the
|
|
||||||
// withBrand(...) pg transaction corrupts Next.js's request-scoped
|
|
||||||
// AsyncLocalStorage and the cookie set throws for users without a
|
|
||||||
// platform login (the route handler returns 500). The cookie must
|
|
||||||
// be written in the OUTER function frame, after withBrand resolves.
|
|
||||||
//
|
|
||||||
// Distinguishing OLD vs NEW: in the old code, `const cookieStore
|
|
||||||
// = await cookies()` was declared INSIDE the withBrand callback
|
|
||||||
// (between `withBrand(` and its matching closing `)`). In the new
|
|
||||||
// code, it's declared AFTER that closing. We find the matching
|
|
||||||
// `)` that closes withBrand (depth-tracked, ignoring string
|
|
||||||
// contents) and assert that `const cookieStore` comes after it.
|
|
||||||
const stripped = stripComments(await getVerifyWaterAdminPinSource());
|
|
||||||
const wbStart = stripped.indexOf("withBrand(");
|
|
||||||
expect(wbStart).toBeGreaterThan(-1);
|
|
||||||
const wbOpenParen = wbStart + "withBrand".length; // position of the `(` after `withBrand`
|
|
||||||
let depth = 1; // we are inside withBrand(
|
|
||||||
let i = wbOpenParen + 1;
|
|
||||||
let inString: string | null = null;
|
|
||||||
let escaped = false;
|
|
||||||
let wbEnd = -1;
|
|
||||||
while (i < stripped.length) {
|
|
||||||
const c = stripped[i];
|
|
||||||
if (inString) {
|
|
||||||
if (escaped) { escaped = false; i++; continue; }
|
|
||||||
if (c === "\\") { escaped = true; i++; continue; }
|
|
||||||
if (c === inString) inString = null;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === '"' || c === "'" || c === "`") { inString = c; i++; continue; }
|
|
||||||
if (c === "(" || c === "{") depth++;
|
|
||||||
else if (c === ")" || c === "}") {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) { wbEnd = i; break; }
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
expect(wbEnd).toBeGreaterThan(-1);
|
|
||||||
const cookieStoreIdx = stripped.indexOf("const cookieStore");
|
|
||||||
expect(cookieStoreIdx).toBeGreaterThan(-1);
|
|
||||||
expect(cookieStoreIdx).toBeGreaterThan(wbEnd);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,145 +239,15 @@ describe("requireFieldSession()", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── requireWaterAdminSession ────────────────────────────────────────────
|
// ── getFieldSessionUser ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("requireWaterAdminSession()", () => {
|
describe("getFieldSessionUser()", () => {
|
||||||
it("returns { ok: false, error: 'Not signed in' } when wl_admin_session cookie is missing", async () => {
|
it("returns null when there is no active field session", async () => {
|
||||||
state.cookieValue = undefined;
|
state.cookieValue = undefined;
|
||||||
const { requireWaterAdminSession } = await import(
|
const { getFieldSessionUser } = await import(
|
||||||
"@/actions/water-log/auth"
|
"@/actions/water-log/auth"
|
||||||
);
|
);
|
||||||
const result = await requireWaterAdminSession();
|
expect(await getFieldSessionUser()).toBeNull();
|
||||||
expect(result).toEqual({ ok: false, error: "Not signed in" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns { ok: false, error: 'Session expired' } when row is past expires_at", async () => {
|
|
||||||
state.cookieValue = "expired-admin";
|
|
||||||
const past = new Date(Date.now() - 60_000);
|
|
||||||
state.mockDb.select.mockReturnValueOnce({
|
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: async () => [
|
|
||||||
{
|
|
||||||
id: "expired-admin",
|
|
||||||
brandId: "b1",
|
|
||||||
adminUserId: "au1",
|
|
||||||
expiresAt: past,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const { requireWaterAdminSession } = await import(
|
|
||||||
"@/actions/water-log/auth"
|
|
||||||
);
|
|
||||||
const result = await requireWaterAdminSession();
|
|
||||||
expect(result).toEqual({ ok: false, error: "Session expired" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns { ok: true, sessionId, brandId, adminUserId } on a happy-path admin session", async () => {
|
|
||||||
state.cookieValue = "valid-admin";
|
|
||||||
const future = new Date(Date.now() + 60_000);
|
|
||||||
state.mockDb.select.mockReturnValueOnce({
|
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: async () => [
|
|
||||||
{
|
|
||||||
id: "valid-admin",
|
|
||||||
brandId: "b1",
|
|
||||||
adminUserId: "au1",
|
|
||||||
expiresAt: future,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const { requireWaterAdminSession } = await import(
|
|
||||||
"@/actions/water-log/auth"
|
|
||||||
);
|
|
||||||
const result = await requireWaterAdminSession();
|
|
||||||
expect(result).toEqual({
|
|
||||||
ok: true,
|
|
||||||
sessionId: "valid-admin",
|
|
||||||
brandId: "b1",
|
|
||||||
adminUserId: "au1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getWaterAdminSession (the "return null on miss" variant) ────────────
|
|
||||||
|
|
||||||
describe("getWaterAdminSession()", () => {
|
|
||||||
it("returns null when cookie is missing", async () => {
|
|
||||||
state.cookieValue = undefined;
|
|
||||||
const { getWaterAdminSession } = await import(
|
|
||||||
"@/actions/water-log/auth"
|
|
||||||
);
|
|
||||||
expect(await getWaterAdminSession()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when session row is missing", async () => {
|
|
||||||
state.cookieValue = "missing";
|
|
||||||
state.mockDb.select.mockReturnValueOnce({
|
|
||||||
from: () => ({
|
|
||||||
where: () => ({ limit: async () => [] }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const { getWaterAdminSession } = await import(
|
|
||||||
"@/actions/water-log/auth"
|
|
||||||
);
|
|
||||||
expect(await getWaterAdminSession()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when past expires_at", async () => {
|
|
||||||
state.cookieValue = "expired";
|
|
||||||
const past = new Date(Date.now() - 1);
|
|
||||||
state.mockDb.select.mockReturnValueOnce({
|
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: async () => [
|
|
||||||
{
|
|
||||||
id: "expired",
|
|
||||||
brandId: "b1",
|
|
||||||
adminUserId: "au1",
|
|
||||||
expiresAt: past,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const { getWaterAdminSession } = await import(
|
|
||||||
"@/actions/water-log/auth"
|
|
||||||
);
|
|
||||||
expect(await getWaterAdminSession()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the session shape on a valid row", async () => {
|
|
||||||
state.cookieValue = "valid";
|
|
||||||
const future = new Date(Date.now() + 60_000);
|
|
||||||
state.mockDb.select.mockReturnValueOnce({
|
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: async () => [
|
|
||||||
{
|
|
||||||
id: "valid",
|
|
||||||
brandId: "b1",
|
|
||||||
adminUserId: "au1",
|
|
||||||
expiresAt: future,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const { getWaterAdminSession } = await import(
|
|
||||||
"@/actions/water-log/auth"
|
|
||||||
);
|
|
||||||
expect(await getWaterAdminSession()).toEqual({
|
|
||||||
sessionId: "valid",
|
|
||||||
brandId: "b1",
|
|
||||||
adminUserId: "au1",
|
|
||||||
role: "water_admin",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,3 +300,117 @@ describe("requireWaterAdminPermission()", () => {
|
|||||||
expect(result).toEqual({ ok: true, adminUser });
|
expect(result).toEqual({ ok: true, adminUser });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── getAdminSession (cookie-only, the new simple auth) ──────────────────
|
||||||
|
|
||||||
|
describe("getAdminSession()", () => {
|
||||||
|
it("returns null when wl_admin_session cookie is missing", async () => {
|
||||||
|
state.cookieValue = undefined;
|
||||||
|
const { getAdminSession } = await import(
|
||||||
|
"@/lib/water-admin-pin-auth"
|
||||||
|
);
|
||||||
|
expect(await getAdminSession()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the session shape on a valid cookie value", async () => {
|
||||||
|
state.cookieValue = "abc-123";
|
||||||
|
const { getAdminSession, TUXEDO_BRAND_ID } = await import(
|
||||||
|
"@/lib/water-admin-pin-auth"
|
||||||
|
);
|
||||||
|
expect(await getAdminSession()).toEqual({
|
||||||
|
sessionId: "abc-123",
|
||||||
|
brandId: TUXEDO_BRAND_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── verifyAdminPin (PIN verify + cookie set, no DB session) ─────────────
|
||||||
|
|
||||||
|
describe("verifyAdminPin()", () => {
|
||||||
|
function makeSettingsRow(overrides: Partial<{
|
||||||
|
enabled: boolean;
|
||||||
|
pinHash: string | null;
|
||||||
|
sessionDurationHours: number;
|
||||||
|
}> = {}) {
|
||||||
|
return {
|
||||||
|
enabled: overrides.enabled ?? true,
|
||||||
|
pinHash: overrides.pinHash ?? "scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
|
||||||
|
sessionDurationHours: overrides.sessionDurationHours ?? 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns ok:false 'No PIN configured' when stored hash is missing", async () => {
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [{ enabled: true, pinHash: null, sessionDurationHours: 8 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth");
|
||||||
|
const result = await verifyAdminPin("1234");
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "No PIN configured — generate one in /admin/water-log/settings",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false 'Admin portal is disabled' when settings.enabled is false", async () => {
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [{ enabled: false, pinHash: "any", sessionDurationHours: 8 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth");
|
||||||
|
const result = await verifyAdminPin("1234");
|
||||||
|
expect(result).toEqual({ ok: false, error: "Admin portal is disabled" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false 'Invalid PIN' on PIN format failure (no DB read)", async () => {
|
||||||
|
const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth");
|
||||||
|
const result = await verifyAdminPin("12ab");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/PIN/);
|
||||||
|
}
|
||||||
|
expect(state.mockWithBrand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:true when PIN matches the stored hash", async () => {
|
||||||
|
// Pre-compute a real scrypt hash for "1234" so verifyPin succeeds.
|
||||||
|
const { hashPin } = await import("@/lib/water-log-pin");
|
||||||
|
const realHash = hashPin("1234");
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [
|
||||||
|
makeSettingsRow({ pinHash: realHash, sessionDurationHours: 4 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { verifyAdminPin, TUXEDO_BRAND_ID } = await import(
|
||||||
|
"@/lib/water-admin-pin-auth"
|
||||||
|
);
|
||||||
|
const result = await verifyAdminPin("1234");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.session.brandId).toBe(TUXEDO_BRAND_ID);
|
||||||
|
expect(typeof result.session.sessionId).toBe("string");
|
||||||
|
expect(result.session.sessionId.length).toBeGreaterThan(8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── clearAdminSession ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("clearAdminSession()", () => {
|
||||||
|
it("returns without throwing", async () => {
|
||||||
|
const { clearAdminSession } = await import(
|
||||||
|
"@/lib/water-admin-pin-auth"
|
||||||
|
);
|
||||||
|
await expect(clearAdminSession()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user