Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 658f6a5b8b | |||
| 015eb33291 |
@@ -0,0 +1,390 @@
|
|||||||
|
# Water Log — remove platform-login dependency — 2026-07-01
|
||||||
|
|
||||||
|
> Type: Bug fix + refactor (auth layer cleanup)
|
||||||
|
> Branch: `fix/water-log-no-platform-login`
|
||||||
|
> Status: Draft — awaiting user review
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The Water Log module is **PIN-based and self-contained**: `/water` (irrigator)
|
||||||
|
and `/water/admin/login` (brand water admin) both authenticate via a
|
||||||
|
4-digit PIN that mints a `wl_session` or `wl_admin_session` cookie. Neither
|
||||||
|
surface is meant to require a platform admin (Neon Auth) login.
|
||||||
|
|
||||||
|
The middleware already agrees — `src/proxy.ts` lists `/water` and
|
||||||
|
`/api/water-photo-upload` among the public routes. The `/water` page
|
||||||
|
itself loads with no cookie and shows the language → role → PIN flow.
|
||||||
|
|
||||||
|
**But every server action in `src/actions/water-log/{field,admin,settings}.ts`
|
||||||
|
opens with `await getSession()` from `@/lib/auth`.** That call hits Neon
|
||||||
|
Auth on every PIN-screen interaction. In production:
|
||||||
|
|
||||||
|
- When `NEON_AUTH_BASE_URL` is misconfigured, `getSession()` short-circuits
|
||||||
|
to `{ data: null, error: null }` and the rest of the action runs.
|
||||||
|
- When it is configured, the wrapper falls through to the real Neon Auth
|
||||||
|
`getSession()`, which (a) checks the request for a session cookie (none
|
||||||
|
is present for `/water` users), (b) makes a network round-trip to
|
||||||
|
`NEON_AUTH_BASE_URL` anyway, and (c) may hang or return an error if the
|
||||||
|
Neon Auth service is slow / unhealthy / cross-region.
|
||||||
|
|
||||||
|
User-visible result: **PIN submission hangs or fails on `/water` even
|
||||||
|
though the user is doing exactly what the design asks them to do — enter
|
||||||
|
a 4-digit PIN — and is correctly authenticated by the cookie they
|
||||||
|
receive in response.**
|
||||||
|
|
||||||
|
The root cause is leftover copy-paste from the site-admin actions, which
|
||||||
|
*do* legitimately call `getSession()` because they sit behind
|
||||||
|
`/admin/water-log/*` and are gated by `admin_users`. The field and
|
||||||
|
admin-PIN actions have no such dependency; the `getSession()` lines are
|
||||||
|
spurious and should be removed.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
- **`/water` PIN entry works with no platform login, no Neon Auth call,
|
||||||
|
no `dev_session` cookie.** A ditch rider in the field can complete the
|
||||||
|
full language → role → PIN → entry flow with no cookies set on the
|
||||||
|
request other than the `wl_session` minted on success.
|
||||||
|
- **`/water/admin/login` works the same way** for the brand water admin
|
||||||
|
portal.
|
||||||
|
- **The site-admin `/admin/water-log/*` surface is unchanged** —
|
||||||
|
it continues to require a platform admin (Neon Auth + `admin_users`).
|
||||||
|
- **No DB migration, no schema change, no env-var change.** Deploy is a
|
||||||
|
pure code change.
|
||||||
|
- **Regression guard:** an E2E test runs a fresh, cookie-free Playwright
|
||||||
|
context against `/water` and proves the PIN flow works.
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- No change to admin-permissions, Neon Auth, or the PIN hashing library.
|
||||||
|
- No change to middleware, page components, or the existing API routes
|
||||||
|
(all of which are already clean — see §6).
|
||||||
|
- No refactor of the irrigation-season / reporting / CSV layers.
|
||||||
|
- No change to the photo-upload or QR-code APIs.
|
||||||
|
- No attempt to enforce brand_id on the field PIN flow beyond the existing
|
||||||
|
data model (irrigators carry a `brand_id`, and `wl_session` is keyed by
|
||||||
|
`irrigator_id`, so brand isolation is preserved by the join).
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
### 4.1 Three auth gates, three helpers
|
||||||
|
|
||||||
|
Every water-log server action is gated by exactly one helper, matched
|
||||||
|
to its consumer:
|
||||||
|
|
||||||
|
| Surface | Cookie | Helper | Backed by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/water` (irrigator) | `wl_session` | `requireFieldSession()` | `cookies()` + `water_sessions` row |
|
||||||
|
| `/water/admin/*` (brand water admin) | `wl_admin_session` | `requireWaterAdminSession()` | `cookies()` + `water_admin_sessions` row |
|
||||||
|
| `/admin/water-log/*` (site admin) | Neon Auth | `requireWaterAdminPermission()` | `getAdminUser()` + `admin_users.can_manage_water_log` |
|
||||||
|
|
||||||
|
The three `require*` helpers in `auth.ts` **never call `getSession()`**.
|
||||||
|
The two PIN-only helpers (`requireFieldSession`, `requireWaterAdminSession`)
|
||||||
|
read the cookie directly, join the cookie value to the appropriate
|
||||||
|
session row in Postgres, and return. No network round-trip, no Neon
|
||||||
|
Auth dependency, no platform-admin requirement.
|
||||||
|
|
||||||
|
The site-admin helper (`requireWaterAdminPermission`) keeps using
|
||||||
|
`getAdminUser()` because that is the correct auth check for
|
||||||
|
`/admin/water-log/*` (which sits behind Neon Auth in the middleware
|
||||||
|
and on the server).
|
||||||
|
|
||||||
|
> Note: the action `verifyWaterAdminPin` (in `settings.ts`) does make a
|
||||||
|
> best-effort `getAdminUser()` call *after* a successful PIN check, but
|
||||||
|
> only to attach a platform-admin `adminUserId` to the new
|
||||||
|
> `water_admin_sessions` row for audit. It is wrapped in try/catch and
|
||||||
|
> falls back to a zero-UUID placeholder. The `wl_admin_session` is
|
||||||
|
> valid either way — that helper call is not an auth gate.
|
||||||
|
|
||||||
|
### 4.2 New file: `src/actions/water-log/auth.ts`
|
||||||
|
|
||||||
|
Centralized auth layer for the water-log module. Holds the three helpers
|
||||||
|
plus small typed return shapes.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/actions/water-log/auth.ts (sketch)
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { withPlatformAdmin } from "@/db/client";
|
||||||
|
import {
|
||||||
|
waterSessions, waterIrrigators,
|
||||||
|
waterAdminSessions, waterAdminSettings,
|
||||||
|
} from "@/db/schema/water-log";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
|
||||||
|
type FieldSession =
|
||||||
|
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
type AdminPinSession =
|
||||||
|
| { ok: true; sessionId: string; brandId: string; adminUserId: string }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
type SiteAdminPermission =
|
||||||
|
| { ok: true; adminUser: AdminUser }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Read wl_session, look up the row + irrigator. No Neon Auth. */
|
||||||
|
export async function requireFieldSession(): Promise<FieldSession> { ... }
|
||||||
|
|
||||||
|
/** Read wl_admin_session, look up the row. No Neon Auth. */
|
||||||
|
export async function requireWaterAdminSession(): Promise<AdminPinSession> { ... }
|
||||||
|
|
||||||
|
/** Site-admin gate: getAdminUser() + can_manage_water_log. */
|
||||||
|
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> { ... }
|
||||||
|
|
||||||
|
/** Read the active water-admin session row (or null). Cheap, idempotent. */
|
||||||
|
export async function getWaterAdminSession(): Promise<AdminPinSession | null> { ... }
|
||||||
|
|
||||||
|
/** Read the active field session row (or null). Cheap, idempotent. */
|
||||||
|
export async function getFieldSessionUser(): Promise<{ userId; name; brandId; role } | null> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The helpers are exact ports of the existing local helpers in
|
||||||
|
`field.ts` and `admin.ts` — behavior is unchanged, only the import path
|
||||||
|
moves.
|
||||||
|
|
||||||
|
### 4.3 File-by-file changes
|
||||||
|
|
||||||
|
**`src/actions/water-log/field.ts`**
|
||||||
|
- Drop `import { getSession } from "@/lib/auth"`.
|
||||||
|
- Drop every `await getSession();` line (10 occurrences).
|
||||||
|
- Move `requireFieldSession`, `getFieldSessionUser`, `getWaterAdminSession`,
|
||||||
|
`getWaterSession` into `auth.ts`. Import from there.
|
||||||
|
- `setWaterLang()` keeps `cookies()` only.
|
||||||
|
- `verifyWaterPin`, `getWaterHeadgates`, `submitWaterEntry`, `logoutWater`,
|
||||||
|
`logoutWaterAdmin` all remain — no behavior change other than
|
||||||
|
dropping the spurious `getSession()` calls.
|
||||||
|
|
||||||
|
**`src/actions/water-log/admin.ts`**
|
||||||
|
- Drop `import { getSession } from "@/lib/auth"`.
|
||||||
|
- Drop every `await getSession();` line (18 occurrences).
|
||||||
|
- Move `requireWaterAdminSession` and `requireWaterAdminPermission` into
|
||||||
|
`auth.ts`. Import from there.
|
||||||
|
- Action-by-action gate stays as today: mutations/reads in `admin.ts`
|
||||||
|
use `requireWaterAdminPermission()` because every action that lives
|
||||||
|
in this file is invoked from `/admin/water-log/*` (site-admin path).
|
||||||
|
|
||||||
|
**`src/actions/water-log/settings.ts`**
|
||||||
|
- Drop `import { getSession } from "@/lib/auth"`.
|
||||||
|
- Drop every `await getSession();` line (4 occurrences, including the
|
||||||
|
one in `verifyWaterAdminPin`).
|
||||||
|
- `getWaterAdminSettings`, `updateWaterAdminSettings`, `regenerateAdminPin`
|
||||||
|
keep their existing `getAdminUser()` gate (site-admin path).
|
||||||
|
- `verifyWaterAdminPin` becomes a pure PIN-only flow: validate format,
|
||||||
|
look up `water_admin_settings` for the brand, verify PIN, mint
|
||||||
|
`wl_admin_session` cookie. **No `getSession()` call.** `getAdminUser()`
|
||||||
|
is still called inside the success path to attach an `adminUserId`
|
||||||
|
to the new session row for audit — wrapped in try/catch, falls back
|
||||||
|
to a zero-UUID placeholder if no admin user is signed in. The
|
||||||
|
session itself is valid either way.
|
||||||
|
|
||||||
|
### 4.4 Why `verifyWaterAdminPin` is a special case
|
||||||
|
|
||||||
|
`verifyWaterAdminPin` is the most-affected function in this PR. Today:
|
||||||
|
|
||||||
|
1. It calls `await getSession()` (Neon Auth) — drop.
|
||||||
|
2. It calls `getAdminUser()` inside the success path to attach the
|
||||||
|
caller's platform-admin user to the new `wl_admin_session` row for
|
||||||
|
audit. This is correct and stays, but the call is now **wrapped in
|
||||||
|
a try/catch that swallows any error and falls back to a zero-UUID
|
||||||
|
`adminUserId`**. The `wl_admin_session` is still valid in either
|
||||||
|
case — the cookie holds the brand + session id, not the admin user.
|
||||||
|
|
||||||
|
This is the right shape because the water-admin PIN flow is for ditch
|
||||||
|
riders / brand water admins, who often aren't platform admins. Tying
|
||||||
|
the brand-admin session to a platform-admin user is convenient for
|
||||||
|
audit, not required for the flow to work.
|
||||||
|
|
||||||
|
## 5. Data flow
|
||||||
|
|
||||||
|
### 5.1 `verifyWaterPin` (irrigator)
|
||||||
|
|
||||||
|
```
|
||||||
|
client POST via server action: verifyWaterPin(brandId, pin)
|
||||||
|
└─ validatePin(pin) ── format check
|
||||||
|
└─ withPlatformAdmin(db => ...) ── look up irrigator
|
||||||
|
WHERE active = true ── across all brands
|
||||||
|
└─ match = irrigators.find(i => verifyPin(...)) ── scrypt compare
|
||||||
|
└─ if no match: 200ms delay, return error
|
||||||
|
└─ withBrand(brandId, db => ...)
|
||||||
|
INSERT water_sessions (irrigator_id, expires_at = now+8h)
|
||||||
|
UPDATE water_irrigators SET last_used_at
|
||||||
|
└─ cookies().set("wl_session", sessionId, ...)
|
||||||
|
└─ return { success: true, role, name, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
No `getSession()`. No `getAdminUser()`. No network call beyond the local
|
||||||
|
DB round-trips.
|
||||||
|
|
||||||
|
### 5.2 `verifyWaterAdminPin` (brand water admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
client POST via server action: verifyWaterAdminPin(brandId, pin)
|
||||||
|
└─ validatePin(pin)
|
||||||
|
└─ withBrand(brandId, db => ...)
|
||||||
|
SELECT water_admin_settings WHERE brand_id
|
||||||
|
if !settings or !settings.enabled or !settings.pinHash → return error
|
||||||
|
if !verifyPin(pin, settings.pinHash) → 200ms delay, return error
|
||||||
|
try { adminUser = await getAdminUser() } catch { adminUser = null }
|
||||||
|
INSERT water_admin_sessions (brand_id, admin_user_id, expires_at)
|
||||||
|
└─ cookies().set("wl_admin_session", sessionId, ...)
|
||||||
|
└─ return { success: true, session_id }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `submitWaterEntry` (irrigator mutation)
|
||||||
|
|
||||||
|
```
|
||||||
|
client POST via server action: submitWaterEntry(...)
|
||||||
|
└─ requireFieldSession() → { ok: false, error } or { ok: true, userId, brandId }
|
||||||
|
└─ validate measurement / notes / lat / lng
|
||||||
|
└─ withBrand(brandId, db => ...)
|
||||||
|
SELECT headgate (verify it belongs to brand)
|
||||||
|
INSERT water_log_entries
|
||||||
|
UPDATE water_headgates SET last_used_at
|
||||||
|
(optional) logAlert() if threshold breach
|
||||||
|
└─ return { success: true, entry_id }
|
||||||
|
```
|
||||||
|
|
||||||
|
If `requireFieldSession()` returns `ok: false`, the action returns
|
||||||
|
`{ success: false, error: "Session expired or invalid" }`. The client
|
||||||
|
(`WaterFieldClient`) already handles this: it resets to the language
|
||||||
|
step and shows the "Session expired" message. **No change to that
|
||||||
|
client behavior is needed.**
|
||||||
|
|
||||||
|
## 6. Error handling
|
||||||
|
|
||||||
|
| Failure | Helper return | Caller return | Client behavior |
|
||||||
|
|---|---|---|---|
|
||||||
|
| No `wl_session` cookie | `{ ok: false, error: "Not logged in" }` | `{ success: false, error: "Session expired or invalid" }` | Reset to language step |
|
||||||
|
| `wl_session` cookie but no matching row | same as above | same | same |
|
||||||
|
| Session row past `expires_at` | same as above (best-effort delete) | same | same |
|
||||||
|
| Irrigator row marked `active = false` | `{ ok: false, error: "User is inactive" }` | `{ success: false, error: "User is inactive" }` | Reset to language step |
|
||||||
|
| `wl_admin_session` missing / expired | `null` (from `getWaterAdminSession`) | `{ success: false, error: "Not signed in" }` | Bounce to `/water/admin/login` |
|
||||||
|
| Site admin not signed in | `{ ok: false, error: "Not signed in" }` | `{ success: false, error: ... }` | Toast + stay |
|
||||||
|
| Site admin signed in but `can_manage_water_log` false | `{ ok: false, error: "Not permitted" }` | `{ success: false, error: "Forbidden" }` | Toast + redirect |
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
### 7.1 Unit (`tests/unit/water-log-auth.test.ts`, new)
|
||||||
|
|
||||||
|
Each helper, no network:
|
||||||
|
|
||||||
|
- `requireFieldSession()`:
|
||||||
|
- Returns `{ ok: false, error: "Not logged in" }` when no cookie.
|
||||||
|
- Returns `{ ok: false, error: "Session expired" }` when cookie points
|
||||||
|
at a row with `expires_at` in the past.
|
||||||
|
- Returns `{ ok: false, error: "Session not found" }` when cookie has
|
||||||
|
no matching row.
|
||||||
|
- Returns `{ ok: false, error: "User is inactive" }` when irrigator
|
||||||
|
`active = false`.
|
||||||
|
- Returns `{ ok: true, userId, brandId, role }` on happy path.
|
||||||
|
- **Asserts no `getSession` import is reachable from this module**
|
||||||
|
(literal `import.meta.glob` or a vitest module-mock trick).
|
||||||
|
|
||||||
|
- `requireWaterAdminSession()`:
|
||||||
|
- Returns `{ ok: false, error: "Not signed in" }` when no cookie.
|
||||||
|
- Returns `{ ok: false, error: "Session expired" }` when past expiry.
|
||||||
|
- Returns `{ ok: true, ... }` on happy path.
|
||||||
|
- No `getSession` import reachable.
|
||||||
|
|
||||||
|
- `requireWaterAdminPermission()`:
|
||||||
|
- Mock `getAdminUser` to return `null` → `{ ok: false, error: "Not signed in" }`.
|
||||||
|
- Mock `getAdminUser` to return user without `can_manage_water_log`
|
||||||
|
→ `{ ok: false, error: "Not permitted" }`.
|
||||||
|
- Mock to return platform_admin → `{ ok: true, ... }`.
|
||||||
|
- This helper is allowed to call `getAdminUser()` — that is its
|
||||||
|
defined behavior. No "no getSession" assertion here.
|
||||||
|
|
||||||
|
### 7.2 E2E (`tests/water-log.spec.ts`, new block)
|
||||||
|
|
||||||
|
New `test.describe("Water Log — no platform login required")`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test("GET /water renders the PIN flow with no cookies set", async ({ browser }) => {
|
||||||
|
// Fresh context: no storageState, no cookies, no dev_session.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const res = await page.goto(`${BASE}/water`);
|
||||||
|
expect(res?.status()).toBe(200);
|
||||||
|
// The page goes through language → role → pin before showing the input.
|
||||||
|
// We assert it never 302s to /login.
|
||||||
|
const allResponses: string[] = [];
|
||||||
|
page.on("response", (r) => allResponses.push(r.url()));
|
||||||
|
// Click through the language step
|
||||||
|
await page.getByRole("button", { name: /english/i }).click();
|
||||||
|
// Click the "Irrigator" button (the lighter one)
|
||||||
|
await page.getByRole("button", { name: /irrigator/i }).click();
|
||||||
|
// PIN input is now visible
|
||||||
|
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
||||||
|
await expect(pin).toBeVisible({ timeout: 10_000 });
|
||||||
|
// Confirm we never hit /login
|
||||||
|
expect(allResponses.filter((u) => u.includes("/login"))).toHaveLength(0);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setWaterLang server action does not require auth", async ({ request }) => {
|
||||||
|
// Empty storage state, no cookies.
|
||||||
|
const ctx = await request.newContext({ baseURL: BASE, storageState: { cookies: [], origins: [] } });
|
||||||
|
// We don't have a direct POST endpoint for setWaterLang; instead hit
|
||||||
|
// the page and check the language cookie gets set without a redirect.
|
||||||
|
const res = await ctx.get("/water");
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `tests/water-log.spec.ts` blocks for the `/water/admin/login`
|
||||||
|
and `/admin/water-log` redirects remain — they continue to pass because
|
||||||
|
we only relax the field and admin-PIN paths, not the site-admin path.
|
||||||
|
|
||||||
|
### 7.3 Manual smoke (post-deploy)
|
||||||
|
|
||||||
|
1. Open `/water` in a private window. Confirm language → role → PIN.
|
||||||
|
2. Enter a wrong PIN — confirm error toast, no redirect to `/login`.
|
||||||
|
3. Enter a correct PIN — confirm the entry form loads with headgates.
|
||||||
|
4. Submit an entry — confirm the success banner.
|
||||||
|
5. Open `/water/admin/login` in another private window. Same flow.
|
||||||
|
6. Sign in as a site admin in a third window, navigate to
|
||||||
|
`/admin/water-log` — confirm it still requires the platform login.
|
||||||
|
|
||||||
|
## 8. Out of scope (explicit)
|
||||||
|
|
||||||
|
- No DB migration. (`water_sessions`, `water_admin_sessions`,
|
||||||
|
`water_admin_settings` already have all columns the auth helpers
|
||||||
|
read.)
|
||||||
|
- No new RPC. The auth helpers run direct Drizzle queries against the
|
||||||
|
pool.
|
||||||
|
- No env-var changes.
|
||||||
|
- No middleware change. `/water` and the water API routes are already
|
||||||
|
in `PUBLIC_ROUTES`.
|
||||||
|
- No change to PIN hashing (`src/lib/water-log-pin.ts` is correct).
|
||||||
|
- No change to the photo-upload route — it already uses the cookie.
|
||||||
|
- No change to QR-code routes — they're truly public.
|
||||||
|
- No change to `/api/water-logs/export` or `/api/v1/water-logs` —
|
||||||
|
both are correctly site-admin-gated via `getAdminUser()` and
|
||||||
|
belong on the platform login.
|
||||||
|
|
||||||
|
## 9. Rollout
|
||||||
|
|
||||||
|
- Single branch `fix/water-log-no-platform-login`, single PR.
|
||||||
|
- Merge → push to `origin/main` → `.gitea/workflows/deploy.yml` runs
|
||||||
|
the normal build + migration gate (no migration in this change).
|
||||||
|
- Existing `wl_session` / `wl_admin_session` cookies continue to work.
|
||||||
|
- No data backfill.
|
||||||
|
- Rollback = revert the commit. The `getSession()` calls being removed
|
||||||
|
were inert in practice (just slow / occasionally failing), so any
|
||||||
|
caller that worked before this change will continue to work after.
|
||||||
|
|
||||||
|
## 10. Open questions
|
||||||
|
|
||||||
|
None at design time. Decisions taken:
|
||||||
|
|
||||||
|
- Helpers move to `auth.ts`, not split across files.
|
||||||
|
- `verifyWaterAdminPin` keeps the best-effort `getAdminUser()` call for
|
||||||
|
audit, wrapped in try/catch.
|
||||||
|
- `getWaterAdminSettings` / `updateWaterAdminSettings` / `regenerateAdminPin`
|
||||||
|
stay site-admin-gated (these mutate brand-level settings, which is a
|
||||||
|
site-admin action — the brand water admin does not manage them).
|
||||||
|
- Field session type union is preserved verbatim from current `field.ts`.
|
||||||
@@ -16,22 +16,21 @@
|
|||||||
*/
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
import { and, desc, eq, gte, lte, sql, SQL } from "drizzle-orm";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import {
|
import {
|
||||||
waterHeadgates,
|
waterHeadgates,
|
||||||
waterIrrigators,
|
waterIrrigators,
|
||||||
waterLogEntries,
|
waterLogEntries,
|
||||||
waterAdminSessions,
|
|
||||||
type WaterHeadgate,
|
type WaterHeadgate,
|
||||||
type WaterIrrigator,
|
type WaterIrrigator,
|
||||||
type WaterLogEntry,
|
type WaterLogEntry,
|
||||||
} from "@/db/schema/water-log";
|
} from "@/db/schema/water-log";
|
||||||
import { hashPin, generatePin, verifyPin } from "@/lib/water-log-pin";
|
import { hashPin, generatePin } from "@/lib/water-log-pin";
|
||||||
import { logAuditEvent, logAlert } from "@/lib/water-log-audit";
|
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||||
import { getSession } from "@/lib/auth";
|
import {
|
||||||
|
requireWaterAdminPermission,
|
||||||
|
} from "@/actions/water-log/auth";
|
||||||
|
|
||||||
// ── Types used by both server and client ────────────────────────────────────
|
// ── Types used by both server and client ────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,54 +107,11 @@ export type AlertLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth helpers ───────────────────────────────────────────────────────────
|
// ── Auth helpers ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
async function requireWaterAdminPermission() {
|
// `requireWaterAdminPermission` and `requireWaterAdminSession` live in
|
||||||
const adminUser = await getAdminUser();
|
// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`;
|
||||||
if (!adminUser) return { ok: false as const, error: "Not authenticated" };
|
// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither
|
||||||
if (
|
// calls the platform-level `getSession()` from `@/lib/auth`.
|
||||||
!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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -229,7 +185,7 @@ export async function createWaterHeadgate(
|
|||||||
options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {},
|
options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {},
|
||||||
): Promise<{ success: boolean; headgate?: AdminHeadgate; error?: string }> {
|
): 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const trimmed = name?.trim();
|
const trimmed = name?.trim();
|
||||||
if (!trimmed) return { success: false, error: "Name is required" };
|
if (!trimmed) return { success: false, error: "Name is required" };
|
||||||
@@ -283,7 +239,7 @@ export async function updateWaterHeadgate(
|
|||||||
status?: AdminHeadgate["status"],
|
status?: AdminHeadgate["status"],
|
||||||
): Promise<{ success: boolean; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
|
||||||
// Headgates aren't directly brand-scoped in the URL — look up brand first.
|
// Headgates aren't directly brand-scoped in the URL — look up brand first.
|
||||||
@@ -337,7 +293,7 @@ export async function deleteWaterHeadgate(
|
|||||||
headgateId: string,
|
headgateId: string,
|
||||||
): Promise<{ success: boolean; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const brand = await withPlatformAdminBrandOf(headgateId);
|
const brand = await withPlatformAdminBrandOf(headgateId);
|
||||||
if (!brand) return { success: false, error: "Headgate not found" };
|
if (!brand) return { success: false, error: "Headgate not found" };
|
||||||
@@ -363,7 +319,7 @@ export async function regenerateHeadgateToken(
|
|||||||
headgateId: string,
|
headgateId: string,
|
||||||
): Promise<{ success: boolean; token?: string; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const brand = await withPlatformAdminBrandOf(headgateId);
|
const brand = await withPlatformAdminBrandOf(headgateId);
|
||||||
if (!brand) return { success: false, error: "Headgate not found" };
|
if (!brand) return { success: false, error: "Headgate not found" };
|
||||||
@@ -391,7 +347,7 @@ export async function getWaterHeadgatesAdmin(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
): Promise<AdminHeadgate[]> {
|
): Promise<AdminHeadgate[]> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return [];
|
if (!auth.ok) return [];
|
||||||
return withBrand(brandId, async (db) => {
|
return withBrand(brandId, async (db) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -407,7 +363,7 @@ await getSession(); const auth = await requireWaterAdminPermission();
|
|||||||
|
|
||||||
export async function getWaterIrrigators(brandId: string): Promise<AdminIrrigator[]> {
|
export async function getWaterIrrigators(brandId: string): Promise<AdminIrrigator[]> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return [];
|
if (!auth.ok) return [];
|
||||||
return withBrand(brandId, async (db) => {
|
return withBrand(brandId, async (db) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -434,7 +390,7 @@ export async function createWaterUser(
|
|||||||
phone?: string | null,
|
phone?: string | null,
|
||||||
): Promise<CreateUserResult> {
|
): Promise<CreateUserResult> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return { success: false, error: auth.error };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const trimmed = name?.trim();
|
const trimmed = name?.trim();
|
||||||
if (!trimmed) return { success: false, error: "Name is required" };
|
if (!trimmed) return { success: false, error: "Name is required" };
|
||||||
@@ -494,7 +450,7 @@ export async function createWaterIrrigator(
|
|||||||
language: string = "en",
|
language: string = "en",
|
||||||
): Promise<CreateUserResult> {
|
): Promise<CreateUserResult> {
|
||||||
|
|
||||||
await getSession(); return createWaterUser(brandId, name, "irrigator", language);
|
return createWaterUser(brandId, name, "irrigator", language);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWaterIrrigator(
|
export async function updateWaterIrrigator(
|
||||||
@@ -507,7 +463,7 @@ export async function updateWaterIrrigator(
|
|||||||
notes?: string | null,
|
notes?: string | null,
|
||||||
): Promise<{ success: boolean; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
|
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
|
||||||
if (!brand) return { success: false, error: "User not found" };
|
if (!brand) return { success: false, error: "User not found" };
|
||||||
@@ -547,7 +503,7 @@ export async function deleteWaterUser(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ success: boolean; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const brand = await withPlatformAdminBrandOfIrrigator(userId);
|
const brand = await withPlatformAdminBrandOfIrrigator(userId);
|
||||||
if (!brand) return { success: false, error: "User not found" };
|
if (!brand) return { success: false, error: "User not found" };
|
||||||
@@ -573,7 +529,7 @@ export async function resetWaterIrrigatorPin(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ success: boolean; pin?: string; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const brand = await withPlatformAdminBrandOfIrrigator(userId);
|
const brand = await withPlatformAdminBrandOfIrrigator(userId);
|
||||||
if (!brand) return { success: false, error: "User not found" };
|
if (!brand) return { success: false, error: "User not found" };
|
||||||
@@ -609,7 +565,7 @@ export async function getWaterEntries(
|
|||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
): Promise<AdminEntry[]> {
|
): Promise<AdminEntry[]> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return [];
|
if (!auth.ok) return [];
|
||||||
const safeLimit = Math.min(Math.max(limit, 1), 1000);
|
const safeLimit = Math.min(Math.max(limit, 1), 1000);
|
||||||
return withBrand(brandId, async (db) => {
|
return withBrand(brandId, async (db) => {
|
||||||
@@ -664,7 +620,7 @@ await getSession(); const auth = await requireWaterAdminPermission();
|
|||||||
|
|
||||||
export async function getWaterEntryById(entryId: string): Promise<AdminEntry | null> {
|
export async function getWaterEntryById(entryId: string): Promise<AdminEntry | null> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return null;
|
if (!auth.ok) return null;
|
||||||
const brand = await withPlatformAdminBrandOfEntry(entryId);
|
const brand = await withPlatformAdminBrandOfEntry(entryId);
|
||||||
if (!brand) return null;
|
if (!brand) return null;
|
||||||
@@ -727,7 +683,7 @@ export async function updateWaterEntry(
|
|||||||
method?: AdminEntry["method"],
|
method?: AdminEntry["method"],
|
||||||
): Promise<{ success: boolean; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
if (!Number.isFinite(measurement) || measurement < 0) {
|
if (!Number.isFinite(measurement) || measurement < 0) {
|
||||||
return { success: false, error: "Measurement must be a non-negative number" };
|
return { success: false, error: "Measurement must be a non-negative number" };
|
||||||
@@ -763,7 +719,7 @@ export async function deleteWaterEntry(
|
|||||||
entryId: string,
|
entryId: string,
|
||||||
): Promise<{ success: boolean; error?: 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 };
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
const brand = await withPlatformAdminBrandOfEntry(entryId);
|
const brand = await withPlatformAdminBrandOfEntry(entryId);
|
||||||
if (!brand) return { success: false, error: "Entry not found" };
|
if (!brand) return { success: false, error: "Entry not found" };
|
||||||
@@ -791,7 +747,7 @@ export async function getWaterDisplaySummary(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
): Promise<WaterDisplaySummary | null> {
|
): Promise<WaterDisplaySummary | null> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return null;
|
if (!auth.ok) return null;
|
||||||
return withBrand(brandId, async (db) => {
|
return withBrand(brandId, async (db) => {
|
||||||
// Headgates with their latest entry
|
// Headgates with their latest entry
|
||||||
@@ -923,7 +879,7 @@ export async function getWaterAlertLog(
|
|||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
): Promise<AlertLogEntry[]> {
|
): Promise<AlertLogEntry[]> {
|
||||||
|
|
||||||
await getSession(); const auth = await requireWaterAdminPermission();
|
const auth = await requireWaterAdminPermission();
|
||||||
if (!auth.ok) return [];
|
if (!auth.ok) return [];
|
||||||
return withBrand(brandId, async (db) => {
|
return withBrand(brandId, async (db) => {
|
||||||
const rows = await db.execute<{
|
const rows = await db.execute<{
|
||||||
|
|||||||
@@ -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
@@ -30,7 +30,12 @@ import {
|
|||||||
} from "@/db/schema/water-log";
|
} from "@/db/schema/water-log";
|
||||||
import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||||
import { logAlert } from "@/lib/water-log-audit";
|
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
|
// Field sessions last 8h — a working day in the field. Long enough
|
||||||
// that a single sign-in covers a morning shift + afternoon shift.
|
// that a single sign-in covers a morning shift + afternoon shift.
|
||||||
@@ -70,8 +75,7 @@ export async function getWaterHeadgates(
|
|||||||
_brandId: string,
|
_brandId: string,
|
||||||
activeOnly: boolean = false,
|
activeOnly: boolean = false,
|
||||||
): Promise<FieldHeadgate[]> {
|
): Promise<FieldHeadgate[]> {
|
||||||
|
return withBrand(TUXEDO_BRAND_ID, async (db) => {
|
||||||
await getSession(); return withBrand(TUXEDO_BRAND_ID, async (db) => {
|
|
||||||
const where = activeOnly
|
const where = activeOnly
|
||||||
? and(
|
? and(
|
||||||
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
|
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
|
||||||
@@ -101,8 +105,7 @@ export async function verifyWaterPin(
|
|||||||
_brandId: string,
|
_brandId: string,
|
||||||
pin: string,
|
pin: string,
|
||||||
): Promise<VerifyPinResult> {
|
): Promise<VerifyPinResult> {
|
||||||
|
// Validate format first (cheap, no DB).
|
||||||
await getSession(); // Validate format first (cheap, no DB).
|
|
||||||
const formatError = validatePin(pin);
|
const formatError = validatePin(pin);
|
||||||
if (formatError) return { success: false, error: formatError };
|
if (formatError) return { success: false, error: formatError };
|
||||||
|
|
||||||
@@ -185,8 +188,7 @@ export async function submitWaterEntry(
|
|||||||
longitude?: number,
|
longitude?: number,
|
||||||
_headgateLocked?: boolean,
|
_headgateLocked?: boolean,
|
||||||
): Promise<SubmitEntryResult> {
|
): Promise<SubmitEntryResult> {
|
||||||
|
// ── Auth ──
|
||||||
await getSession(); // ── Auth ──
|
|
||||||
const session = await requireFieldSession();
|
const session = await requireFieldSession();
|
||||||
if (!session.ok) return { success: false, error: session.error };
|
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 ───────────────────────────────────────────────
|
// ── Cookie/language helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function logoutWater(): Promise<void> {
|
export async function logoutWater(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
await getSession(); const cookieStore = await cookies();
|
|
||||||
const sessionId = cookieStore.get("wl_session")?.value;
|
const sessionId = cookieStore.get("wl_session")?.value;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
// Best-effort DB cleanup
|
// Best-effort DB cleanup
|
||||||
@@ -368,8 +302,7 @@ await getSession(); const cookieStore = await cookies();
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutWaterAdmin(): Promise<void> {
|
export async function logoutWaterAdmin(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
await getSession(); const cookieStore = await cookies();
|
|
||||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
@@ -385,58 +318,8 @@ await getSession(); const cookieStore = await cookies();
|
|||||||
cookieStore.delete("wl_admin_session");
|
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> {
|
export async function setWaterLang(lang: string): Promise<void> {
|
||||||
|
|
||||||
if (!["en", "es"].includes(lang)) return;
|
if (!["en", "es"].includes(lang)) return;
|
||||||
await getSession();
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set("wl_lang", lang, {
|
cookieStore.set("wl_lang", lang, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
||||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
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 = {
|
export type AdminSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -56,7 +59,7 @@ export async function getWaterAdminSettings(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
): Promise<AdminSettings | null> {
|
): Promise<AdminSettings | null> {
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
if (
|
if (
|
||||||
!adminUser.can_manage_water_log &&
|
!adminUser.can_manage_water_log &&
|
||||||
@@ -88,7 +91,7 @@ export async function saveWaterAdminSettings(
|
|||||||
settings: Partial<AdminSettings>,
|
settings: Partial<AdminSettings>,
|
||||||
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
|
): 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) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
!adminUser.can_manage_water_log &&
|
!adminUser.can_manage_water_log &&
|
||||||
@@ -152,7 +155,7 @@ export async function regenerateAdminPin(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
): Promise<{ success: boolean; pin?: string; error?: 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) return { success: false, error: "Not authenticated" };
|
||||||
if (
|
if (
|
||||||
!adminUser.can_manage_water_log &&
|
!adminUser.can_manage_water_log &&
|
||||||
@@ -199,7 +202,7 @@ export async function verifyWaterAdminPin(
|
|||||||
pin: string,
|
pin: string,
|
||||||
): Promise<{ success: boolean; session_id?: string; error?: 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 };
|
if (formatError) return { success: false, error: formatError };
|
||||||
|
|
||||||
return withBrand(brandId, async (db) => {
|
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).
|
// 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(
|
const expiresAt = new Date(
|
||||||
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
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 { 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";
|
||||||
|
|||||||
@@ -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/field";
|
import { getWaterAdminSession } from "@/actions/water-log/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";
|
||||||
|
|||||||
@@ -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/field";
|
import { getWaterAdminSession } from "@/actions/water-log/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";
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for `src/actions/water-log/auth.ts` — the centralized auth
|
||||||
|
* helpers for the water log module.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ── Hoisted state (visible inside vi.mock factory closures) ────────────
|
||||||
|
|
||||||
|
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>,
|
||||||
|
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.mockGetAdminUser = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
vi.mock("@/db/client", () => ({
|
||||||
|
withPlatformAdmin: state.mockWithPlatformAdmin,
|
||||||
|
withBrand: vi.fn(),
|
||||||
|
withDb: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin-permissions", () => ({
|
||||||
|
getAdminUser: state.mockGetAdminUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
cookies: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
get: () =>
|
||||||
|
state.cookieValue !== undefined
|
||||||
|
? { value: state.cookieValue }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
headers: () => Promise.resolve(new Headers()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.cookieValue = undefined;
|
||||||
|
state.mockDb.select.mockReset();
|
||||||
|
state.mockWithPlatformAdmin.mockClear();
|
||||||
|
state.mockGetAdminUser.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Module guards (the regression this whole PR exists for) ─────────────
|
||||||
|
|
||||||
|
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(
|
||||||
|
path.resolve(process.cwd(), "src/actions/water-log/auth.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const stripped = raw
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "")
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/);
|
||||||
|
expect(stripped).not.toMatch(/\bgetSession\s*\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── requireFieldSession ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("requireFieldSession()", () => {
|
||||||
|
it("returns { ok: false, error: 'Not logged in' } when wl_session cookie is missing", async () => {
|
||||||
|
state.cookieValue = undefined;
|
||||||
|
const { requireFieldSession } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireFieldSession();
|
||||||
|
expect(result).toEqual({ ok: false, error: "Not logged in" });
|
||||||
|
expect(state.mockWithPlatformAdmin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: false, error: 'Session not found' } when cookie has no matching row", async () => {
|
||||||
|
state.cookieValue = "missing-session-id";
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
innerJoin: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { requireFieldSession } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireFieldSession();
|
||||||
|
expect(result).toEqual({ ok: false, error: "Session not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: false, error: 'Session expired' } and best-effort deletes the row when past expires_at", async () => {
|
||||||
|
state.cookieValue = "expired-id";
|
||||||
|
const past = new Date(Date.now() - 60_000);
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
innerJoin: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [
|
||||||
|
{
|
||||||
|
session: { id: "expired-id", expiresAt: past },
|
||||||
|
irrigator: {
|
||||||
|
id: "u1",
|
||||||
|
brandId: "b1",
|
||||||
|
role: "irrigator",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const mockDelete = vi.fn().mockReturnValue({
|
||||||
|
where: async () => undefined,
|
||||||
|
});
|
||||||
|
(state.mockDb as unknown as { select: typeof vi.fn; delete: typeof vi.fn }).delete =
|
||||||
|
mockDelete;
|
||||||
|
const { requireFieldSession } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireFieldSession();
|
||||||
|
expect(result).toEqual({ ok: false, error: "Session expired" });
|
||||||
|
expect(mockDelete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: false, error: 'User is inactive' } when irrigator is soft-deleted", async () => {
|
||||||
|
state.cookieValue = "valid-id";
|
||||||
|
const future = new Date(Date.now() + 60_000);
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
innerJoin: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [
|
||||||
|
{
|
||||||
|
session: { id: "valid-id", expiresAt: future },
|
||||||
|
irrigator: {
|
||||||
|
id: "u1",
|
||||||
|
brandId: "b1",
|
||||||
|
role: "irrigator",
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { requireFieldSession } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireFieldSession();
|
||||||
|
expect(result).toEqual({ ok: false, error: "User is inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: true, userId, brandId, role } on a happy-path session", async () => {
|
||||||
|
state.cookieValue = "valid-id";
|
||||||
|
const future = new Date(Date.now() + 60_000);
|
||||||
|
state.mockDb.select.mockReturnValueOnce({
|
||||||
|
from: () => ({
|
||||||
|
innerJoin: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [
|
||||||
|
{
|
||||||
|
session: { id: "valid-id", expiresAt: future },
|
||||||
|
irrigator: {
|
||||||
|
id: "u1",
|
||||||
|
brandId: "b1",
|
||||||
|
role: "irrigator",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { requireFieldSession } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireFieldSession();
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
userId: "u1",
|
||||||
|
brandId: "b1",
|
||||||
|
role: "irrigator",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── requireWaterAdminSession ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("requireWaterAdminSession()", () => {
|
||||||
|
it("returns { ok: false, error: 'Not signed in' } when wl_admin_session cookie is missing", async () => {
|
||||||
|
state.cookieValue = undefined;
|
||||||
|
const { requireWaterAdminSession } = 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── requireWaterAdminPermission (site-admin gate) ───────────────────────
|
||||||
|
|
||||||
|
describe("requireWaterAdminPermission()", () => {
|
||||||
|
it("returns { ok: false, error: 'Not signed in' } when getAdminUser is null", async () => {
|
||||||
|
state.mockGetAdminUser.mockResolvedValue(null);
|
||||||
|
const { requireWaterAdminPermission } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireWaterAdminPermission();
|
||||||
|
expect(result).toEqual({ ok: false, error: "Not signed in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: false, error: 'Not authorized' } when admin lacks can_manage_water_log and is not platform_admin", async () => {
|
||||||
|
state.mockGetAdminUser.mockResolvedValue({
|
||||||
|
user_id: "u1",
|
||||||
|
role: "brand_admin",
|
||||||
|
can_manage_water_log: false,
|
||||||
|
});
|
||||||
|
const { requireWaterAdminPermission } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireWaterAdminPermission();
|
||||||
|
expect(result).toEqual({ ok: false, error: "Not authorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: true, adminUser } when platform_admin", async () => {
|
||||||
|
const adminUser = { user_id: "u1", role: "platform_admin" };
|
||||||
|
state.mockGetAdminUser.mockResolvedValue(adminUser);
|
||||||
|
const { requireWaterAdminPermission } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireWaterAdminPermission();
|
||||||
|
expect(result).toEqual({ ok: true, adminUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: true, adminUser } when brand_admin with can_manage_water_log", async () => {
|
||||||
|
const adminUser = {
|
||||||
|
user_id: "u1",
|
||||||
|
role: "brand_admin",
|
||||||
|
can_manage_water_log: true,
|
||||||
|
};
|
||||||
|
state.mockGetAdminUser.mockResolvedValue(adminUser);
|
||||||
|
const { requireWaterAdminPermission } = await import(
|
||||||
|
"@/actions/water-log/auth"
|
||||||
|
);
|
||||||
|
const result = await requireWaterAdminPermission();
|
||||||
|
expect(result).toEqual({ ok: true, adminUser });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,6 +66,78 @@ test.describe("Water Log — public surfaces", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Water Log — no platform login required (regression)", () => {
|
||||||
|
// The water log module is PIN-based and self-contained. A prior
|
||||||
|
// version of the server actions called getSession() (Neon Auth) on
|
||||||
|
// every PIN-screen interaction, which caused `/water` to hang or
|
||||||
|
// fail for users with no platform login. These tests assert that
|
||||||
|
// a fresh, cookie-free browser context can complete the public
|
||||||
|
// surfaces without ever touching /login.
|
||||||
|
|
||||||
|
test("/water full flow does not redirect to /login with no cookies", async ({ browser }) => {
|
||||||
|
// Fresh context: no storageState, no cookies, no dev_session.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const hits: string[] = [];
|
||||||
|
page.on("request", (r) => {
|
||||||
|
const u = r.url();
|
||||||
|
if (u.startsWith(BASE)) hits.push(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await page.goto(`${BASE}/water`, { waitUntil: "domcontentloaded" });
|
||||||
|
expect(res?.status()).toBe(200);
|
||||||
|
|
||||||
|
// Walk through language → role. The exact button labels depend on
|
||||||
|
// the i18n, but the Irrigator path is the green button labeled
|
||||||
|
// "Irrigator" and the language step is the first English/Español
|
||||||
|
// choice.
|
||||||
|
await page.getByRole("button", { name: /^english$/i }).click({ timeout: 5_000 }).catch(async () => {
|
||||||
|
// Some renderings show "English" as a label rather than exact
|
||||||
|
// text — fall back to the first button in the language picker.
|
||||||
|
await page.locator("button").filter({ hasText: /english/i }).first().click();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: /^irrigator$/i }).click({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// The PIN input is now visible.
|
||||||
|
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
||||||
|
await expect(pin).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// We must never have hit /login.
|
||||||
|
const loginHits = hits.filter((u) => u.includes("/login"));
|
||||||
|
expect(loginHits).toEqual([]);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/water-admin-auth works with no cookies (returns 401, not 302/500)", async () => {
|
||||||
|
const ctx = await request.newContext({
|
||||||
|
baseURL: BASE,
|
||||||
|
storageState: { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
// Any 4-digit PIN, no cookies — must NOT 302 to /login and must
|
||||||
|
// NOT 500 (server error). 401/403 are the correct gates.
|
||||||
|
const res = await ctx.post("/api/water-admin-auth", {
|
||||||
|
data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "0000" },
|
||||||
|
maxRedirects: 0,
|
||||||
|
});
|
||||||
|
expect([200, 401, 403]).toContain(res.status());
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/water responds 200 to a no-cookie GET (no Neon Auth hang)", async () => {
|
||||||
|
const ctx = await request.newContext({
|
||||||
|
baseURL: BASE,
|
||||||
|
storageState: { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
// Bounded timeout — if the server-action pre-call is hanging on
|
||||||
|
// Neon Auth, this will throw. 8s is generous for a static page
|
||||||
|
// render and tight enough to fail fast on a hang.
|
||||||
|
const res = await ctx.get("/water", { timeout: 8_000 });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("Water Log — site-admin gate", () => {
|
test.describe("Water Log — site-admin gate", () => {
|
||||||
test("/admin/water-log redirects unauthenticated users", async ({ page }) => {
|
test("/admin/water-log redirects unauthenticated users", async ({ page }) => {
|
||||||
await page.goto(`${BASE}/admin/water-log`, { waitUntil: "domcontentloaded" });
|
await page.goto(`${BASE}/admin/water-log`, { waitUntil: "domcontentloaded" });
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: [
|
||||||
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
||||||
|
// Specific sub-paths must come before the general @/db catch-all.
|
||||||
|
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") },
|
||||||
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
||||||
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
||||||
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
||||||
|
|||||||
Reference in New Issue
Block a user