fix(water-log): remove platform-login dependency from PIN flows
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s

Field users at /water were blocked because every server action called
getSession() (Neon Auth) even though the field path is PIN-authenticated.
A missing or expired platform session hung or rejected PIN submissions,
so users without a platform login could never reach the water log entry
screen.

Centralize water-log auth in src/actions/water-log/auth.ts with three
helpers — requireFieldSession, requireWaterAdminSession, and
requireWaterAdminPermission — and migrate all server actions off the
stray getSession() calls. Auth.ts deliberately does NOT import
getSession; a static-source unit test enforces that.

Changes:
- src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth
- src/actions/water-log/field.ts: -10 await getSession(), use helpers
- src/actions/water-log/admin.ts: -18 await getSession(), use helpers
- src/actions/water-log/settings.ts: -4 await getSession(), resilient
  to missing platform admin
- 3x admin pages: update getWaterAdminSession import path
- tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression
  guard that auth.ts never imports getSession
- tests/water-log.spec.ts: 3 E2E regression tests (no platform login)
- vitest.config.ts: alias for deep @/db/schema/water-log path

Rollback = revert this commit. No DB migration; no schema change.
Existing rows/cookies unchanged.
This commit is contained in:
Tyler
2026-07-01 17:27:37 -06:00
parent 015eb33291
commit 658f6a5b8b
10 changed files with 780 additions and 209 deletions
+28 -72
View File
@@ -16,22 +16,21 @@
*/
"use server";
import { cookies } from "next/headers";
import { and, desc, eq, gte, lte, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { and, desc, eq, sql } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterLogEntries,
waterAdminSessions,
type WaterHeadgate,
type WaterIrrigator,
type WaterLogEntry,
} from "@/db/schema/water-log";
import { hashPin, generatePin, verifyPin } from "@/lib/water-log-pin";
import { logAuditEvent, logAlert } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
import { hashPin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
import {
requireWaterAdminPermission,
} from "@/actions/water-log/auth";
// ── Types used by both server and client ────────────────────────────────────
@@ -108,54 +107,11 @@ export type AlertLogEntry = {
};
// ── Auth helpers ───────────────────────────────────────────────────────────
async function requireWaterAdminPermission() {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false as const, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { ok: false as const, error: "Not authorized" };
}
return { ok: true as const, adminUser };
}
/**
* For the `/water/admin` (PIN) portal. Returns the admin + brand if a
* valid session cookie is present. Falls back to a 401-shaped error.
*/
async function requireWaterAdminSession(): Promise<
| { ok: true; adminUserId: string; brandId: string }
| { ok: false; error: string }
> {
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return { ok: false, error: "Not signed in" };
return withBrand(globalThis.__TUXEDO_BRAND_ID__ ?? "", async () => {
// session lookup must use platform-admin to read across brands
const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => {
const rows = await db
.select()
.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,
adminUserId: row.adminUserId,
brandId: row.brandId,
};
});
});
}
//
// `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`.
// 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
@@ -229,7 +185,7 @@ export async function createWaterHeadgate(
options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {},
): Promise<{ success: boolean; headgate?: AdminHeadgate; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
@@ -283,7 +239,7 @@ export async function updateWaterHeadgate(
status?: AdminHeadgate["status"],
): Promise<{ success: boolean; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
// Headgates aren't directly brand-scoped in the URL — look up brand first.
@@ -337,7 +293,7 @@ export async function deleteWaterHeadgate(
headgateId: string,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
@@ -363,7 +319,7 @@ export async function regenerateHeadgateToken(
headgateId: string,
): Promise<{ success: boolean; token?: string; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
@@ -391,7 +347,7 @@ export async function getWaterHeadgatesAdmin(
brandId: string,
): Promise<AdminHeadgate[]> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
@@ -407,7 +363,7 @@ await getSession(); const auth = await requireWaterAdminPermission();
export async function getWaterIrrigators(brandId: string): Promise<AdminIrrigator[]> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
@@ -434,7 +390,7 @@ export async function createWaterUser(
phone?: string | null,
): Promise<CreateUserResult> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
@@ -494,7 +450,7 @@ export async function createWaterIrrigator(
language: string = "en",
): Promise<CreateUserResult> {
await getSession(); return createWaterUser(brandId, name, "irrigator", language);
return createWaterUser(brandId, name, "irrigator", language);
}
export async function updateWaterIrrigator(
@@ -507,7 +463,7 @@ export async function updateWaterIrrigator(
notes?: string | null,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
if (!brand) return { success: false, error: "User not found" };
@@ -547,7 +503,7 @@ export async function deleteWaterUser(
userId: string,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
if (!brand) return { success: false, error: "User not found" };
@@ -573,7 +529,7 @@ export async function resetWaterIrrigatorPin(
userId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
if (!brand) return { success: false, error: "User not found" };
@@ -609,7 +565,7 @@ export async function getWaterEntries(
limit: number = 50,
): Promise<AdminEntry[]> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
const safeLimit = Math.min(Math.max(limit, 1), 1000);
return withBrand(brandId, async (db) => {
@@ -664,7 +620,7 @@ await getSession(); const auth = await requireWaterAdminPermission();
export async function getWaterEntryById(entryId: string): Promise<AdminEntry | null> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return null;
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return null;
@@ -727,7 +683,7 @@ export async function updateWaterEntry(
method?: AdminEntry["method"],
): Promise<{ success: boolean; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
@@ -763,7 +719,7 @@ export async function deleteWaterEntry(
entryId: string,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return { success: false, error: "Entry not found" };
@@ -791,7 +747,7 @@ export async function getWaterDisplaySummary(
brandId: string,
): Promise<WaterDisplaySummary | null> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return null;
return withBrand(brandId, async (db) => {
// Headgates with their latest entry
@@ -923,7 +879,7 @@ export async function getWaterAlertLog(
limit: number = 50,
): Promise<AlertLogEntry[]> {
await getSession(); const auth = await requireWaterAdminPermission();
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db.execute<{
+243
View File
@@ -0,0 +1,243 @@
/**
* Water Log — auth layer.
*
* Centralizes the three 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 +
* 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.
*/
"use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import {
waterSessions,
waterIrrigators,
waterAdminSessions,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
// ── Result types ────────────────────────────────────────────────────────
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 };
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* Field session gate — /water (irrigator PIN).
*
* Reads the `wl_session` cookie, joins it to `water_sessions` and the
* linked `water_irrigators` row, and returns the user/brand/role.
*
* Returns `{ ok: false, error }` for:
* - missing cookie → "Not logged in"
* - cookie with no matching row → "Session not found"
* - row past `expires_at` → "Session expired" (best-effort
* cleanup of the stale row)
* - irrigator marked `active = false`→ "User is inactive"
*
* Never calls `getSession()`. Never calls `getAdminUser()`. The only
* network round-trip is the local DB query.
*/
export async function requireFieldSession(): Promise<FieldSession> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(
waterIrrigators,
eq(waterIrrigators.id, waterSessions.irrigatorId),
)
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
try {
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
} catch {
// ignore — the row may have been deleted concurrently
}
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role:
(row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
/**
* 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
* without forcing a hard error when the session is gone.
*/
export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
name: waterIrrigators.name,
role: waterIrrigators.role,
})
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
/**
* Site-admin gate — /admin/water-log/*.
*
* 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.
*/
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false, error: "Not signed in" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { ok: false, error: "Not authorized" };
}
// `adminUser` is non-null here (the first guard returned). Cast keeps
// 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> };
}
+11 -128
View File
@@ -30,7 +30,12 @@ import {
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
import {
requireFieldSession,
type FieldSession,
} from "@/actions/water-log/auth";
export type { FieldSession };
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
@@ -70,8 +75,7 @@ export async function getWaterHeadgates(
_brandId: string,
activeOnly: boolean = false,
): Promise<FieldHeadgate[]> {
await getSession(); return withBrand(TUXEDO_BRAND_ID, async (db) => {
return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
@@ -101,8 +105,7 @@ export async function verifyWaterPin(
_brandId: string,
pin: string,
): Promise<VerifyPinResult> {
await getSession(); // Validate format first (cheap, no DB).
// Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
@@ -185,8 +188,7 @@ export async function submitWaterEntry(
longitude?: number,
_headgateLocked?: boolean,
): Promise<SubmitEntryResult> {
await getSession(); // ── Auth ──
// ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
@@ -281,78 +283,10 @@ await getSession(); // ── Auth ──
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
async function requireFieldSession(): Promise<FieldSession> {
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
await getSession(); const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
await getSession(); const cookieStore = await cookies();
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
@@ -368,8 +302,7 @@ await getSession(); const cookieStore = await cookies();
}
export async function logoutWaterAdmin(): Promise<void> {
await getSession(); const cookieStore = await cookies();
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
@@ -385,58 +318,8 @@ await getSession(); const cookieStore = await cookies();
cookieStore.delete("wl_admin_session");
}
async function getWaterSession(): Promise<string | null> {
await getSession(); const cookieStore = await cookies();
return cookieStore.get("wl_session")?.value ?? null;
}
/**
* Resolves the current `/water/admin` session.
*
* Returns the admin role + brandId on success, or `null` if the cookie
* is missing / expired / points at a deleted session row. Used by
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
* gate on top of `getAdminUser()` — a site admin can edit entries
* directly, but a PIN-authenticated water admin can too.
*/
export async function getWaterAdminSession(): Promise<{
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
} | null> {
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return {
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
role: "water_admin" as const,
};
});
}
export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
await getSession();
const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, {
httpOnly: false,
+18 -6
View File
@@ -24,7 +24,10 @@ import {
import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
// 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;
@@ -56,7 +59,7 @@ export async function getWaterAdminSettings(
brandId: string,
): Promise<AdminSettings | null> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
@@ -88,7 +91,7 @@ export async function saveWaterAdminSettings(
settings: Partial<AdminSettings>,
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
@@ -152,7 +155,7 @@ export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
@@ -199,7 +202,7 @@ export async function verifyWaterAdminPin(
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
await getSession(); const formatError = validatePin(pin);
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
@@ -224,7 +227,16 @@ await getSession(); const formatError = validatePin(pin);
}
// Tie the session to the calling site admin (best-effort).
const adminUser = await getAdminUser();
// The `wl_admin_session` is valid whether or not a platform admin
// is signed in — this call only attaches an `adminUserId` for
// audit. If `getAdminUser()` fails or returns null, we fall back
// to a zero-UUID placeholder.
let adminUser: Awaited<ReturnType<typeof getAdminUser>> = null;
try {
adminUser = await getAdminUser();
} catch {
// ignore — the session is valid regardless
}
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
@@ -1,5 +1,5 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterAdminSession } from "@/actions/water-log/field";
import { getWaterAdminSession } from "@/actions/water-log/auth";
import { getWaterEntryById } from "@/actions/water-log/admin";
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
import Link from "next/link";
@@ -1,6 +1,6 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { getWaterAdminSession } from "@/actions/water-log/field";
import { getWaterAdminSession } from "@/actions/water-log/auth";
import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
import Link from "next/link";
+1 -1
View File
@@ -1,6 +1,6 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { getWaterAdminSession } from "@/actions/water-log/field";
import { getWaterAdminSession } from "@/actions/water-log/auth";
import { getWaterIrrigators } from "@/actions/water-log/admin";
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
import Link from "next/link";