Files
route-commerce/src/app/admin/water-log/settings/page.tsx
T
RouteComm Dev ad4d3c9976 feat(smartsheet): cycle 7 — workbook hub
Replaces two independent Smartsheet configs (water log + time tracking)
with one brand-level workbook connection that owns the encrypted API
token. Per-feature configs keep their sheet_id + column_mapping +
frequency + sync_enabled.

Schema (migration 0096):
- NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token
  + token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id,
  connection_enabled (master switch), last_test_*, audit columns. RLS
  brand-scoped.
- Backfill: water_smartsheet_config rows copy into workspace first;
  time_tracking_smartsheet_config fills in only brands with no workspace
  row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING).
- DROP encrypted_api_token + token_iv + token_auth_tag from both feature
  configs (destructive — bytes are copied to workspace first).
- Drop unused bytea customType from both schemas (no longer needed).

Actions:
- NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace,
  saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection,
  disableSmartsheetWorkspace. Permission: can_manage_settings +
  platform_admin.
- NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken
  helper. Lives outside the 'use server' file so the plaintext token
  is not exposed as an RPC surface — only callable from server-side
  sync engines.
- MODIFIED water-log + time-tracking smartsheet actions: refactored to
  read token via resolveWorkspaceToken; save* no longer accepts a
  token; test* falls through to the workspace token if no token given.
- MODIFIED both sync services: load token from resolveWorkspaceToken
  instead of decrypting the per-feature config. 'connection_disabled'
  now skips cleanly (no retry, no failed log row) — pausing the
  workspace at the hub level no longer floods Recent Activity with
  'disabled' failures.

UI:
- NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub
  card. Owns the token + Test Connection + connection enabled toggle +
  default sheet ID + last-verified badge. Permission gated.
- MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without
  token UI. Reads masked token from workspace. Sheet ID + column
  mapping + frequency + enabled + backfill + recent activity.
- MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment.
- MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a
  single Smartsheet section with the hub on top + both feature cards
  as siblings underneath.

Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings
page; matches existing single-tenant /water convention).

Security fixes from pr-reviewer:
- resolveWorkspaceToken extracted from 'use server' file to a
  server-only service module — was reachable as an RPC, would have
  returned the plaintext token to any authenticated admin.
- getSmartsheetWorkspace now actually checks the auth result instead
  of calling requireManageSettings() and discarding the return value.

Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card).
2026-07-03 19:18:49 -06:00

589 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* /admin/water-log/settings
*
* Site-admin-facing settings page for the Water Log module. Controls:
* - Whether `/water/admin` is enabled
* - 4-digit admin PIN (regenerate on demand)
* - Session duration (hours)
* - Per-coarse-permission flags (edit / delete / export)
* - High/low alert phone + enable flag
*
* Visual language: Field Almanac. Same cream + forest palette as
* the main WaterLogAdminPanel, but trimmed to a single column for
* a focused "form" feel.
*/
import { useReducer, useEffect, useCallback } from "react";
import {
getWaterAdminSettings,
saveWaterAdminSettings,
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
import TimeTrackingSmartsheetCard from "@/components/admin/time-tracking/TimeTrackingSmartsheetCard";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
type Message = { type: "success" | "error"; text: string } | null;
type State = {
settings: AdminSettings | null;
loading: boolean;
saving: boolean;
regenerating: boolean;
revealedPin: string | null;
message: Message;
enabled: boolean;
sessionDuration: number;
canEdit: boolean;
canDelete: boolean;
canExport: boolean;
alertPhone: string;
alertsEnabled: boolean;
};
type Action =
| { type: "LOAD_START" }
| {
type: "LOAD_SUCCESS";
data: AdminSettings;
}
| { type: "LOAD_DONE" }
| { type: "SAVE_START" }
| { type: "SAVE_SUCCESS"; settings: AdminSettings | undefined }
| { type: "SAVE_FAIL"; message: Message }
| { type: "SAVE_DONE" }
| { type: "REGEN_START" }
| { type: "REGEN_SUCCESS"; pin: string; message: Message }
| { type: "REGEN_FAIL"; message: Message }
| { type: "REGEN_DONE" }
| { type: "DISMISS_PIN" }
| { type: "CLEAR_MESSAGE" }
| { type: "SET_ENABLED"; value: boolean }
| { type: "SET_SESSION_DURATION"; value: number }
| { type: "SET_CAN_EDIT"; value: boolean }
| { type: "SET_CAN_DELETE"; value: boolean }
| { type: "SET_CAN_EXPORT"; value: boolean }
| { type: "SET_ALERT_PHONE"; value: string }
| { type: "SET_ALERTS_ENABLED"; value: boolean };
const initialState: State = {
settings: null,
loading: true,
saving: false,
regenerating: false,
revealedPin: null,
message: null,
enabled: false,
sessionDuration: 12,
canEdit: true,
canDelete: true,
canExport: true,
alertPhone: "",
alertsEnabled: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "LOAD_START":
return { ...state, loading: true };
case "LOAD_SUCCESS":
return {
...state,
settings: action.data,
enabled: action.data.enabled,
sessionDuration: action.data.sessionDurationHours,
canEdit: action.data.canEditEntries,
canDelete: action.data.canDeleteEntries,
canExport: action.data.canExportCsv,
alertPhone: action.data.alertPhone ?? "",
alertsEnabled: action.data.alertsEnabled,
};
case "LOAD_DONE":
return { ...state, loading: false };
case "SAVE_START":
return { ...state, saving: true, message: null };
case "SAVE_SUCCESS":
return {
...state,
message: { type: "success", text: "Settings saved" },
settings: action.settings ?? state.settings,
};
case "SAVE_FAIL":
return { ...state, message: action.message };
case "SAVE_DONE":
return { ...state, saving: false };
case "REGEN_START":
return { ...state, regenerating: true, message: null };
case "REGEN_SUCCESS":
return {
...state,
revealedPin: action.pin,
message: action.message,
};
case "REGEN_FAIL":
return { ...state, message: action.message };
case "REGEN_DONE":
return { ...state, regenerating: false };
case "DISMISS_PIN":
return { ...state, revealedPin: null };
case "CLEAR_MESSAGE":
return { ...state, message: null };
case "SET_ENABLED":
return { ...state, enabled: action.value };
case "SET_SESSION_DURATION":
return { ...state, sessionDuration: action.value };
case "SET_CAN_EDIT":
return { ...state, canEdit: action.value };
case "SET_CAN_DELETE":
return { ...state, canDelete: action.value };
case "SET_CAN_EXPORT":
return { ...state, canExport: action.value };
case "SET_ALERT_PHONE":
return { ...state, alertPhone: action.value };
case "SET_ALERTS_ENABLED":
return { ...state, alertsEnabled: action.value };
default:
return state;
}
}
export default function WaterLogSettingsPage() {
const [state, dispatch] = useReducer(reducer, initialState);
const {
settings,
loading,
saving,
regenerating,
revealedPin,
message,
enabled,
sessionDuration,
canEdit,
canDelete,
canExport,
alertPhone,
alertsEnabled,
} = state;
const loadSettings = useCallback(async () => {
dispatch({ type: "LOAD_START" });
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
if (data) {
dispatch({ type: "LOAD_SUCCESS", data });
}
dispatch({ type: "LOAD_DONE" });
}, []);
useEffect(() => {
// Load on mount — setState-in-effect is intentional here
// because we're hydrating from server data.
void loadSettings();
}, [loadSettings]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
dispatch({ type: "SAVE_START" });
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled,
sessionDurationHours: sessionDuration,
canEditEntries: canEdit,
canDeleteEntries: canDelete,
canExportCsv: canExport,
alertPhone: alertPhone || null,
alertsEnabled,
});
if (result.success) {
dispatch({ type: "SAVE_SUCCESS", settings: result.settings });
} else {
dispatch({
type: "SAVE_FAIL",
message: { type: "error", text: result.error ?? "Save failed" },
});
}
dispatch({ type: "SAVE_DONE" });
}
async function handleRegenerate() {
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
return;
}
dispatch({ type: "REGEN_START" });
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
if (result.success && result.pin) {
dispatch({
type: "REGEN_SUCCESS",
pin: result.pin,
message: {
type: "success",
text: "New PIN generated. Save it now — it will not be shown again.",
},
});
} else {
dispatch({
type: "REGEN_FAIL",
message: { type: "error", text: result.error ?? "Failed to regenerate PIN" },
});
}
dispatch({ type: "REGEN_DONE" });
}
if (loading) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
<span className="text-[#5a5d5a]">Loading</span>
</div>
);
}
return (
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
{/* Header */}
<div className="border-b border-[#d4d9d3] bg-white">
<div className="mx-auto max-w-2xl px-6 py-6">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 Settings</p>
<h1
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Water Log · Admin Portal
</h1>
<p className="mt-2 text-sm text-[#5a5d5a]">
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
This is separate from the platform admin login.
</p>
</div>
</div>
<div className="mx-auto max-w-2xl px-6 py-8">
<form onSubmit={handleSave} className="space-y-6">
{message && (
<div
role={message.type === "error" ? "alert" : "status"}
aria-live={message.type === "error" ? "assertive" : "polite"}
className={`smartsheet-slide-down flex items-start gap-3 rounded-lg border px-4 py-3 text-[13.5px] font-medium
${
message.type === "success"
? "border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.05] text-[#143d24]"
: "border-[#a4452b]/25 bg-[#a4452b]/[0.05] text-[#7a341f]"
}`}
>
<span
aria-hidden
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white ${
message.type === "success" ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
}`}
>
{message.type === "success" ? (
<svg viewBox="0 0 12 12" className="h-3 w-3">
<path
d="M2 6.5L5 9.5L10 3.5"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 12 12" className="h-3 w-3">
<path
d="M6 3V6.5M6 8.5V9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)}
</span>
<p className="leading-snug">{message.text}</p>
</div>
)}
{/* Enable toggle */}
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
<ToggleRow
label="Enable"
description="Allow water admins to sign in with a 4-digit PIN."
value={enabled}
onChange={(value) => dispatch({ type: "SET_ENABLED", value })}
/>
</Card>
{enabled && (
<>
{/* PIN */}
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
{revealedPin ? (
<div className="space-y-3">
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
New PIN write it down
</p>
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
<span
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
>
{revealedPin}
</span>
</div>
<button
type="button"
onClick={() => dispatch({ type: "DISMISS_PIN" })}
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
>
I&apos;ve saved it dismiss
</button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-[#5a5d5a]">
{settings?.pin === null
? "Generate a new PIN to give to your water admin."
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
</p>
<button
type="button"
onClick={handleRegenerate}
disabled={regenerating}
className="inline-flex min-h-[40px] shrink-0 items-center justify-center rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2
text-[14px] font-semibold text-white
motion-safe:transition-[background-color,transform] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#143d24]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30
active:scale-[0.97] active:bg-[#0f2e1c]
disabled:opacity-50 disabled:cursor-not-allowed"
>
{regenerating ? (
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
Generating
</span>
) : (
"Regenerate PIN"
)}
</button>
</div>
)}
</Card>
{/* Session Duration */}
<Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1168 hours)">
<div className="flex items-center gap-4">
<input
type="range"
min={1}
max={168}
value={sessionDuration}
onChange={(e) =>
dispatch({
type: "SET_SESSION_DURATION",
value: parseInt(e.target.value, 10),
})
}
className="flex-1 accent-[#1a4d2e]"
aria-label="Session duration in hours"
/>
<span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
{sessionDuration}h
</span>
</div>
</Card>
{/* Permissions */}
<Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
<div className="divide-y divide-[#d4d9d3]">
<ToggleRow
label="Edit entries"
description="Allow modifying existing log entries."
value={canEdit}
onChange={(value) => dispatch({ type: "SET_CAN_EDIT", value })}
/>
<ToggleRow
label="Delete entries"
description="Allow removing log entries."
value={canDelete}
onChange={(value) => dispatch({ type: "SET_CAN_DELETE", value })}
/>
<ToggleRow
label="Export CSV"
description="Allow exporting water log data."
value={canExport}
onChange={(value) => dispatch({ type: "SET_CAN_EXPORT", value })}
/>
</div>
</Card>
{/* High/Low Alerts */}
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
<ToggleRow
label="Enable alerts"
description="Threshold breach notifications."
value={alertsEnabled}
onChange={(value) => dispatch({ type: "SET_ALERTS_ENABLED", value })}
/>
{alertsEnabled && (
<div className="mt-4">
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
Alert phone number
</label>
<input aria-label="+13035551234"
id="water-alert-phone"
type="tel"
value={alertPhone}
onChange={(e) =>
dispatch({ type: "SET_ALERT_PHONE", value: e.target.value })
}
placeholder="+13035551234"
className="mt-1 block min-h-[44px] w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5
text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
autoComplete="tel"
/>
<p className="mt-1 text-xs text-[#8a8b88]">
U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
</p>
</div>
)}
</Card>
</>
)}
<button
type="submit"
disabled={saving}
className="inline-flex min-h-[48px] w-full items-center justify-center rounded-lg bg-[#1a4d2e] px-6 py-3
text-[14px] font-semibold tracking-tight text-white
shadow-[0_1px_0_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.06)]
motion-safe:transition-[background-color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#143d24] hover:shadow-[0_2px_4px_rgba(15,23,18,0.1),inset_0_1px_0_rgba(255,255,255,0.06)]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fdfaf2]
active:scale-[0.985] active:bg-[#0f2e1c]
disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
Saving
</span>
) : (
"Save changes"
)}
</button>
</form>
{/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
The hub card owns the brand-level workbook connection (token +
test + enable); the per-feature cards below pick which sheet
inside the workbook to write to. */}
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 05 Integrations
</p>
<h2
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Smartsheet
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
Connect one Smartsheet workbook and pick which sheet each
feature writes to. The token is encrypted at rest; per-feature
cards below manage only their sheet mapping.
</p>
<div className="mt-6">
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
</div>
</div>
{/* Water Log sheet mapping — uses the workbook token from the
hub card above. Configures which sheet inside the workbook
receives water-log entries. */}
<div className="mt-8">
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
</div>
{/* Time Tracking sheet mapping — also uses the workbook token.
Configures which sheet inside the workbook receives
clock-out rows. */}
<div className="mt-8">
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
</div>
{/* § 06 removed in Cycle 7 — Time Tracking sheet mapping is now
a sibling card under § 05 above (uses the workbook token). */}
</div>
</div>
);
}
function Card({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[#d4d9d3] bg-white/95 p-6 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)]">
<header className="mb-5">
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
{title}
</h2>
{subtitle && (
<p className="mt-1 text-[13px] leading-snug text-[#5a5d5a]">
{subtitle}
</p>
)}
</header>
{children}
</section>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
{description && <p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>}
</div>
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(!value)}
className={`group relative inline-flex h-[26px] w-[46px] shrink-0 items-center rounded-full
motion-safe:transition-colors motion-safe:duration-200
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25 focus-visible:ring-offset-2 focus-visible:ring-offset-white
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out
${value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"}`}
>
<span
className={`inline-block h-[20px] w-[20px] transform rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.2),0_1px_1px_rgba(0,0,0,0.06)]
motion-safe:transition-transform motion-safe:duration-200
motion-safe:ease-[cubic-bezier(0.32,0.72,0,1)]
${value ? "translate-x-[22px]" : "translate-x-[3px]"}`}
/>
</button>
</div>
);
}