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 };
|
||||
+150
-248
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* Unit tests for `src/actions/water-log/auth.ts` — the centralized auth
|
||||
* helpers for the water log module.
|
||||
* Unit tests for the water-log auth layer.
|
||||
*
|
||||
* These helpers are the gate for every field/admin-PIN server action
|
||||
* (`/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.
|
||||
* Two modules are exercised here:
|
||||
*
|
||||
* If a future refactor accidentally re-introduces a `getSession()` call
|
||||
* in this module, the `does not import getSession from @/lib/auth` test
|
||||
* below will fail.
|
||||
* - `src/actions/water-log/auth.ts`
|
||||
* The platform-side auth helpers (`requireFieldSession`,
|
||||
* `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";
|
||||
|
||||
@@ -20,19 +26,24 @@ const state = vi.hoisted(() => ({
|
||||
cookieValue: undefined as string | undefined,
|
||||
mockDb: { select: vi.fn() } as { select: 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>,
|
||||
}));
|
||||
|
||||
state.mockWithPlatformAdmin = vi.fn(
|
||||
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();
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/db/client", () => ({
|
||||
withPlatformAdmin: state.mockWithPlatformAdmin,
|
||||
withBrand: vi.fn(),
|
||||
withBrand: state.mockWithBrand,
|
||||
withDb: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -47,6 +58,8 @@ vi.mock("next/headers", () => ({
|
||||
state.cookieValue !== undefined
|
||||
? { value: state.cookieValue }
|
||||
: undefined,
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}),
|
||||
headers: () => Promise.resolve(new Headers()),
|
||||
}));
|
||||
@@ -55,6 +68,7 @@ beforeEach(() => {
|
||||
state.cookieValue = undefined;
|
||||
state.mockDb.select.mockReset();
|
||||
state.mockWithPlatformAdmin.mockClear();
|
||||
state.mockWithBrand.mockClear();
|
||||
state.mockGetAdminUser.mockReset();
|
||||
});
|
||||
|
||||
@@ -62,9 +76,6 @@ beforeEach(() => {
|
||||
|
||||
describe("water-log/auth — module surface", () => {
|
||||
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 path = await import("node:path");
|
||||
const raw = await fs.readFile(
|
||||
@@ -80,114 +91,21 @@ describe("water-log/auth — module surface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Module guard for the Tuxedo admin-PIN entry flow ───────────────────
|
||||
//
|
||||
// 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> {
|
||||
describe("water-admin-pin-auth — module surface", () => {
|
||||
it("does not import getSession from @/lib/auth", async () => {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const raw = await fs.readFile(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"src/actions/water-log/settings.ts",
|
||||
),
|
||||
path.resolve(process.cwd(), "src/lib/water-admin-pin-auth.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const idx = raw.indexOf("export async function verifyWaterAdminPin");
|
||||
if (idx === -1) throw new Error("verifyWaterAdminPin not found");
|
||||
return raw.slice(idx);
|
||||
}
|
||||
|
||||
function stripComments(src: string): string {
|
||||
return src
|
||||
const stripped = raw
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
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(/from\s+["']@\/lib\/auth["']/);
|
||||
expect(stripped).not.toMatch(/\bgetSession\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);
|
||||
expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,145 +239,15 @@ describe("requireFieldSession()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireWaterAdminSession ────────────────────────────────────────────
|
||||
// ── getFieldSessionUser ─────────────────────────────────────────────────
|
||||
|
||||
describe("requireWaterAdminSession()", () => {
|
||||
it("returns { ok: false, error: 'Not signed in' } when wl_admin_session cookie is missing", async () => {
|
||||
describe("getFieldSessionUser()", () => {
|
||||
it("returns null when there is no active field session", async () => {
|
||||
state.cookieValue = undefined;
|
||||
const { requireWaterAdminSession } = await import(
|
||||
const { getFieldSessionUser } = await import(
|
||||
"@/actions/water-log/auth"
|
||||
);
|
||||
const result = await requireWaterAdminSession();
|
||||
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",
|
||||
});
|
||||
expect(await getFieldSessionUser()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,3 +300,117 @@ describe("requireWaterAdminPermission()", () => {
|
||||
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