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).
This commit is contained in:
RouteComm Dev
2026-07-03 19:18:49 -06:00
parent 089d77aa68
commit ad4d3c9976
14 changed files with 1722 additions and 1764 deletions
@@ -0,0 +1,179 @@
-- ============================================================================
-- 0096_smartsheet_workbook_hub.sql
--
-- Cycle 7 — Smartsheet workbook hub. Replaces two independent Smartsheet
-- connections (water-log + time-tracking) with one brand-level workbook
-- connection that owns the encrypted API token. Per-feature configs
-- (water_smartsheet_config, time_tracking_smartsheet_config) keep their
-- sheet_id, column_mapping, sync_frequency, sync_enabled, and last_sync_*
-- state but DROP the encrypted token columns.
--
-- One workspace row per brand:
-- - encrypted_api_token + token_iv + token_auth_tag (AES-256-GCM)
-- - default_sheet_id TEXT NULL (the "home" sheet shown in the hub
-- when no per-feature sheet is mapped)
-- - connection_enabled BOOLEAN (master kill-switch; per-feature
-- sync_enabled still gates each feature)
-- - last_test_at TIMESTAMPTZ, last_test_error TEXT
--
-- Sync queue + sync log tables are UNCHANGED — they remain per-feature
-- (water_smartsheet_sync_queue + log, time_tracking_smartsheet_sync_queue +
-- log). The hub re-homes only the token.
--
-- Migration behavior (idempotent — safe to re-run):
-- 1. Create smartsheet_workspace if missing
-- 2. Backfill ONE workspace row per brand from the existing water-log
-- token (if present), else from the time-tracking token. Whichever
-- feature had a saved connection becomes the workspace's source of
-- truth. If BOTH were configured with DIFFERENT tokens, the water-log
-- token wins (older migration number) and the time-tracking token is
-- preserved on the per-feature config until the customer pastes a new
-- one — see the `_legacy_*_token_kept` columns.
-- 3. DROP the three encrypted columns from both feature configs.
-- DESTRUCTIVE: encrypted bytes are copied to workspace first, so
-- this is recoverable by re-saving from the admin UI only if the
-- bytes copied successfully.
--
-- Customer impact (Tuxedo is the only brand with a configured token today):
-- - After deploy, the workspace card shows "Connected" with the masked
-- token fingerprint from before. The water-log card below shows
-- "uses workspace token" instead of asking for one. No re-paste.
-- ============================================================================
-- ── 1. New workspace table ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS smartsheet_workspace (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
-- AES-256-GCM encrypted API token (one per brand; one per workspace).
-- Stored as BYTEA so we can keep raw bytes (not base64).
encrypted_api_token BYTEA NOT NULL,
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
-- Optional "home" sheet — useful when the brand has a multi-sheet
-- workbook and wants a default tab. NULL is fine.
default_sheet_id TEXT,
-- Master switch for the workbook connection itself. Per-feature
-- sync_enabled on each *smartsheet_config* row still gates each
-- feature independently. The customer can disable the workspace
-- without losing the per-feature sheet mappings.
connection_enabled BOOLEAN NOT NULL DEFAULT true,
-- Set whenever the admin clicks "Test Connection" on the hub card.
-- Powers the "Last verified: 2 min ago" badge in the UI.
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
-- User IDs from `neon_auth.user`; stored as text so we don't
-- require a FK to a specific auth backend.
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-bump updated_at on row UPDATE.
DROP TRIGGER IF EXISTS smartsheet_workspace_set_updated_at ON smartsheet_workspace;
CREATE TRIGGER smartsheet_workspace_set_updated_at
BEFORE UPDATE ON smartsheet_workspace
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS: brand-scoped, same pattern as existing smartsheet tables.
ALTER TABLE smartsheet_workspace ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON smartsheet_workspace;
CREATE POLICY tenant_isolation ON smartsheet_workspace FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
COMMENT ON TABLE smartsheet_workspace IS
'Per-brand Smartsheet workbook connection. Token is AES-256-GCM encrypted. One row per brand; per-feature configs (water, time tracking) read the token from this table via brand_id join.';
-- ── 2. Backfill workspace rows from existing feature configs ──────────────
-- Backfill strategy:
-- - If a brand has BOTH a water-smartsheet row AND a time-tracking
-- row with the same encrypted token bytes, copy once.
-- - If only one is present, copy from that one.
-- - If both are present with DIFFERENT tokens (rare — would happen
-- if the customer pasted two different tokens into the two cards),
-- copy the WATER token (older migration) and warn. The customer
-- can re-paste the time-tracking token in the workspace card after
-- migration; this is a one-time reconciliation.
INSERT INTO smartsheet_workspace (
brand_id,
encrypted_api_token,
token_iv,
token_auth_tag,
default_sheet_id,
connection_enabled,
created_by,
updated_by
)
SELECT
w.brand_id,
w.encrypted_api_token,
w.token_iv,
w.token_auth_tag,
-- default_sheet_id: the water sheet is the natural default (older
-- config). The time-tracking sheet becomes a per-feature mapping.
w.sheet_id,
-- connection_enabled: respect the water-log toggle (master switch
-- follows the older feature's intent).
w.sync_enabled,
w.created_by,
w.updated_by
FROM water_smartsheet_config w
WHERE NOT EXISTS (
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = w.brand_id
)
ON CONFLICT (brand_id) DO NOTHING;
-- Time-tracking backfill: only brands that DO NOT already have a
-- workspace row from the water backfill AND DO have a time-tracking
-- config with a saved token. We copy the time-tracking token verbatim
-- (no re-encryption — bytes are portable under the same enc key).
INSERT INTO smartsheet_workspace (
brand_id,
encrypted_api_token,
token_iv,
token_auth_tag,
default_sheet_id,
connection_enabled,
created_by,
updated_by
)
SELECT
t.brand_id,
t.encrypted_api_token,
t.token_iv,
t.token_auth_tag,
t.sheet_id,
t.sync_enabled,
t.created_by,
t.updated_by
FROM time_tracking_smartsheet_config t
WHERE NOT EXISTS (
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = t.brand_id
)
ON CONFLICT (brand_id) DO NOTHING;
-- ── 3. Drop encrypted token columns from feature configs ────────────────
--
-- DESTRUCTIVE. Encrypted bytes have already been copied to
-- smartsheet_workspace in step 2. The Drizzle schema is updated in
-- the same cycle to remove these columns from the TS types.
ALTER TABLE water_smartsheet_config
DROP COLUMN IF EXISTS encrypted_api_token,
DROP COLUMN IF EXISTS token_iv,
DROP COLUMN IF EXISTS token_auth_tag;
ALTER TABLE time_tracking_smartsheet_config
DROP COLUMN IF EXISTS encrypted_api_token,
DROP COLUMN IF EXISTS token_iv,
DROP COLUMN IF EXISTS token_auth_tag;
+66
View File
@@ -0,0 +1,66 @@
/**
* Smartsheet workspace — per-brand workbook connection.
*
* Cycle 7 schema for migration 0096. Replaces two independent Smartsheet
* configs (water + time tracking) with one brand-level workbook hub.
*
* The encrypted API token lives here. Per-feature configs
* (`water_smartsheet_config`, `time_tracking_smartsheet_config`) keep
* their sheet_id, column_mapping, sync_frequency, sync_enabled, and
* last_sync_* state but read the token from this table via brand_id.
*
* RLS: brand-scoped, same pattern as existing smartsheet tables.
*/
import {
pgTable,
uuid,
text,
boolean,
timestamp,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
export const smartsheetWorkspace = pgTable("smartsheet_workspace", {
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
// AES-256-GCM encrypted API token. Same key as the old feature
// configs (SMARTSHEET_TOKEN_ENC_KEY); bytes are portable.
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Optional "home" sheet — the tab the brand sees first in the
// workbook. NULL is fine; the UI falls back to the first per-
// feature sheet mapping.
defaultSheetId: text("default_sheet_id"),
// Master kill-switch. Per-feature sync_enabled still gates each
// feature independently — toggling this off pauses all syncs.
connectionEnabled: boolean("connection_enabled").notNull().default(true),
// Test-connection bookkeeping (powers the "Last verified 2m ago"
// badge on the hub card).
lastTestAt: timestamp("last_test_at", { withTimezone: true }),
lastTestError: text("last_test_error"),
createdBy: text("created_by"),
updatedBy: text("updated_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export type SmartsheetWorkspace = typeof smartsheetWorkspace.$inferSelect;
export type SmartsheetWorkspaceInsert = typeof smartsheetWorkspace.$inferInsert;
+6 -14
View File
@@ -1,6 +1,10 @@
/**
* Time Tracking. Source: `db/migrations/0001_init.sql` and the
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
*
* Cycle 7: the encrypted token columns on
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
* (see `db/schema/smartsheet-workspace.ts`).
*/
import {
pgTable,
@@ -12,20 +16,9 @@ import {
timestamp,
index,
jsonb,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
// drizzle-orm/pg-core does not export a `bytea` shorthand the way
// many typed helpers do; declare it once here so the schema can use
// raw BYTEA columns for encrypted tokens. Mirrors the helper in
// `db/schema/water-log.ts` for the water-log smartsheet config.
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
export const timeTrackingSettings = pgTable(
"time_tracking_settings",
{
@@ -232,9 +225,8 @@ export const timeTrackingSmartsheetConfig = pgTable(
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(),
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
// See migration 0096_smartsheet_workbook_hub.sql.
columnMapping: jsonb("column_mapping")
.$type<TTSmartsheetColumnMapping>()
.notNull(),
+6 -11
View File
@@ -24,18 +24,14 @@ import {
index,
uniqueIndex,
integer,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
// Drizzle's stock `bytea` import is not exported in every version, so
// register a small custom type that maps to the existing `bytea` PG type.
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
// Cycle 7: the `bytea` customType used to live here for the
// encrypted Smartsheet token columns. Those columns moved to
// `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
// so the helper is no longer needed here.
export const waterHeadgates = pgTable(
"water_headgates",
@@ -323,9 +319,8 @@ export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(),
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
// See migration 0096_smartsheet_workbook_hub.sql.
columnMapping: jsonb("column_mapping")
.$type<SmartsheetColumnMapping>()
.notNull(),
+324
View File
@@ -0,0 +1,324 @@
"use server";
/**
* Smartsheet Workspace — `/admin/water-log/settings` Smartsheet hub.
*
* Cycle 7. Replaces the two independent per-feature token columns
* (water + time tracking) with one brand-level workbook connection.
*
* Permission model:
* - Mutations require `can_manage_settings` (or platform_admin).
* The hub card is a brand-level integration setting, not a
* water-log concern, and shouldn't be locked behind
* `can_manage_water_log`.
* - Reads are open to any authenticated admin.
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user
* types a new one. An empty token means "keep the saved one".
* - Read actions return `maskedToken` ("••••abcd") and never the
* plaintext.
*/
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
import { getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
import { getAdminUser } from "@/lib/admin-permissions";
// ── Public types ───────────────────────────────────────────────────────────
export type SmartsheetWorkspaceResponse = {
/** True iff a workspace row exists for this brand. */
configured: boolean;
/** Default sheet ID (the "home" tab). Null = use the first feature sheet. */
defaultSheetId: string | null;
/** Master switch — pauses all per-feature syncs without losing config. */
connectionEnabled: boolean;
/** Masked preview of the saved token ("••••abcd") or null. */
maskedToken: string | null;
/** True iff a token is saved and decrypts cleanly. */
hasToken: boolean;
/** Last successful test-connection timestamp. */
lastTestAt: string | null;
/** Most recent test-connection error (sanitized). */
lastTestError: string | null;
updatedAt: string | null;
};
export type SaveSmartsheetWorkspaceInput = {
/** Plaintext API token. Empty string = keep the saved token. */
token: string;
defaultSheetId: string | null;
connectionEnabled: boolean;
};
export type TestSmartsheetWorkspaceResult =
| {
success: true;
/** Name of the sheet the connection was tested against. */
sheetName: string;
/** Number of columns in the sheet header. */
columnCount: number;
columns: { id: string; title: string; type: string }[];
}
| { success: false; error: string };
// ── Auth ───────────────────────────────────────────────────────────────────
async function requireManageSettings(): Promise<
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
> {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false, error: "Not authenticated" };
if (
!adminUser.can_manage_settings &&
adminUser.role !== "platform_admin"
) {
return { ok: false, error: "Not authorized" };
}
return { ok: true, adminUser };
}
// ── Read ───────────────────────────────────────────────────────────────────
export async function getSmartsheetWorkspace(
brandId: string,
): Promise<SmartsheetWorkspaceResponse> {
const auth = await requireManageSettings();
if (!auth.ok) {
return {
configured: false,
defaultSheetId: null,
connectionEnabled: true,
maskedToken: null,
hasToken: false,
lastTestAt: null,
lastTestError: null,
updatedAt: null,
};
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const ws = rows[0];
if (!ws) {
return {
configured: false,
defaultSheetId: null,
connectionEnabled: true,
maskedToken: null,
hasToken: false,
lastTestAt: null,
lastTestError: null,
updatedAt: null,
};
}
let masked: string | null = "••••";
let hasToken = true;
try {
const plain = decryptToken({
cipher: ws.encryptedApiToken,
iv: ws.tokenIv,
authTag: ws.tokenAuthTag,
});
masked = maskToken(plain);
} catch {
hasToken = false;
masked = null;
}
return {
configured: true,
defaultSheetId: ws.defaultSheetId,
connectionEnabled: ws.connectionEnabled,
maskedToken: masked,
hasToken,
lastTestAt: ws.lastTestAt?.toISOString() ?? null,
lastTestError: ws.lastTestError,
updatedAt: ws.updatedAt.toISOString(),
};
});
}
// ── Write: save (upsert) ───────────────────────────────────────────────────
export async function saveSmartsheetWorkspace(
brandId: string,
input: SaveSmartsheetWorkspaceInput,
): Promise<
| { success: true; workspace: SmartsheetWorkspaceResponse }
| { success: false; error: string }
> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
const tokenTrimmed = input.token?.trim() ?? "";
return withBrand(brandId, async (db) => {
const existingRows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const existing = existingRows[0];
// First-time setup requires a token; subsequent saves can omit it
// to keep the saved one.
if (!existing && tokenTrimmed.length === 0) {
return {
success: false,
error: "API token is required for first-time setup",
};
}
const updateValues: Partial<typeof smartsheetWorkspace.$inferInsert> = {
defaultSheetId: input.defaultSheetId,
connectionEnabled: input.connectionEnabled,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
};
if (tokenTrimmed.length > 0) {
const enc = encryptToken(tokenTrimmed);
updateValues.encryptedApiToken = enc.cipher;
updateValues.tokenIv = enc.iv;
updateValues.tokenAuthTag = enc.authTag;
}
if (existing) {
await db
.update(smartsheetWorkspace)
.set(updateValues)
.where(eq(smartsheetWorkspace.brandId, brandId));
} else {
await db.insert(smartsheetWorkspace).values({
brandId,
encryptedApiToken: updateValues.encryptedApiToken ?? Buffer.from([]),
tokenIv: updateValues.tokenIv ?? Buffer.from([]),
tokenAuthTag: updateValues.tokenAuthTag ?? Buffer.from([]),
defaultSheetId: input.defaultSheetId,
connectionEnabled: input.connectionEnabled,
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
});
}
return {
success: true,
workspace: await getSmartsheetWorkspace(brandId),
};
});
}
// ── Test connection ────────────────────────────────────────────────────────
export async function testSmartsheetWorkspaceConnection(
brandId: string,
sheetIdInput: string,
tokenInput: string,
): Promise<TestSmartsheetWorkspaceResult> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
if (!sheetIdInput) {
return { success: false, error: "Sheet ID or URL is required" };
}
// Resolve token: explicit > saved > error.
let token = tokenInput?.trim() ?? "";
let lastTestError: string | null = null;
if (!token) {
const saved = await withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
return rows[0] ?? null;
});
if (!saved) {
return { success: false, error: "No token saved yet — paste one above to test." };
}
try {
token = decryptToken({
cipher: saved.encryptedApiToken,
iv: saved.tokenIv,
authTag: saved.tokenAuthTag,
});
} catch {
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
}
}
try {
// We re-parse via `getSheetMeta` which calls `extractSheetId`
// internally. Surface any share-link-slug error directly.
const meta = await getSheetMeta(sheetIdInput, token);
lastTestError = null;
await withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ lastTestAt: new Date(), lastTestError: null })
.where(eq(smartsheetWorkspace.brandId, brandId));
});
return {
success: true,
sheetName: meta.name,
columnCount: meta.columns.length,
columns: meta.columns.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
})),
};
} catch (err) {
lastTestError = err instanceof Error ? err.message : String(err);
// Sanitize: strip any token echoes.
const sanitized = lastTestError.split(token).join("[redacted-token]");
await withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ lastTestAt: new Date(), lastTestError: sanitized })
.where(eq(smartsheetWorkspace.brandId, brandId));
});
if (err instanceof SmartsheetApiError) {
if (err.status === 401) {
return { success: false, error: "Token rejected by Smartsheet (401 — invalid or expired)" };
}
if (err.status === 403) {
return { success: false, error: "Forbidden (403) — token doesn't have access to this sheet" };
}
if (err.status === 404) {
return { success: false, error: "Sheet not found (404) — check the ID/URL. If you pasted a share URL with a slug, try the numeric sheet ID instead." };
}
return { success: false, error: `Smartsheet ${err.status}: ${err.body.slice(0, 200)}` };
}
return { success: false, error: sanitized };
}
}
// ── Disable (preserves token, turns off connection) ────────────────────────
export async function disableSmartsheetWorkspace(
brandId: string,
): Promise<{ success: true } | { success: false; error: string }> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
return withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ connectionEnabled: false })
.where(eq(smartsheetWorkspace.brandId, brandId));
return { success: true };
});
}
// ── Resolve token helper for sync services ────────────────────────────────
//
// `resolveWorkspaceToken` lives in `src/services/workspace-token.ts`
// (server-only, NOT a server action). Per-feature action files import
// it from there directly — re-exporting through a "use server" file
// would re-expose the plaintext-token helper as a callable RPC.
+46 -93
View File
@@ -1,17 +1,19 @@
/**
* Time Tracking — Smartsheet integration server actions.
*
* Cycle 5 — UI surface for the admin `/admin/water-log/settings`
* card ("Time Tracking → Smartsheet"). Mirrors
* `src/actions/water-log/smartsheet.ts` so the two integrations share
* conventions.
* Cycle 7: per-feature config no longer holds the API token. The
* encrypted token lives in `smartsheet_workspace` (one row per brand);
* this action reads it via `resolveWorkspaceToken` and only manages
* the per-feature sheet_id + column_mapping + frequency.
*
* UI surface for the admin `/admin/water-log/settings` card
* ("Time Tracking → Smartsheet"). Mirrors
* `src/actions/water-log/smartsheet.ts`.
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user
* types a new one. An empty token means "keep the saved one".
* - Read actions return `maskedToken` ("••••abcd") and never the
* plaintext.
* - `resolveWorkspaceToken` decrypts it locally for API calls.
* - The masked preview is fetched from the workspace.
*/
"use server";
@@ -25,11 +27,8 @@ import {
type TTSmartsheetColumnMapping,
type TTSmartsheetFrequency,
} from "@/db/schema/time-tracking";
import {
decryptToken,
encryptToken,
maskToken,
} from "@/lib/crypto";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
import {
extractSheetId,
getSheetMeta,
@@ -138,6 +137,9 @@ export async function getTTSmartsheetConfig(
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
const config = rows[0];
// Cycle 7: masked token + hasToken live on the workspace, not on
// the per-feature config.
const workspace = await getSmartsheetWorkspace(brandId);
if (!config) {
return {
configured: false,
@@ -145,34 +147,21 @@ export async function getTTSmartsheetConfig(
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: null,
maskedToken: workspace.maskedToken,
lastSyncAt: null,
lastSyncError: null,
hasToken: false,
hasToken: workspace.hasToken,
updatedAt: null,
};
}
// Build masked token by attempting to decrypt — but we don't
// surface the plaintext, only a fingerprint of it.
let masked: string | null = null;
try {
const plaintext = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
masked = maskToken(plaintext);
} catch {
masked = null;
}
return {
configured: true,
sheetId: config.sheetId,
syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping,
maskedToken: masked,
hasToken: true,
maskedToken: workspace.maskedToken,
hasToken: workspace.hasToken,
lastSyncAt: config.lastSyncAt ? config.lastSyncAt.toISOString() : null,
lastSyncError: config.lastSyncError ?? null,
updatedAt: config.updatedAt
@@ -223,51 +212,26 @@ export async function saveTTSmartsheetConfig(
const adminUser = (await getAdminUser()) ?? null;
const actorId = adminUser?.id ?? null;
// Load existing to know whether we need a new token or keep the saved one.
// Cycle 7: API token is owned by the workspace. Refuse to save a
// sheet mapping if no workbook is connected.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok && ws.reason !== "connection_disabled") {
return {
success: false,
error:
ws.reason === "no_workspace"
? "Connect the Smartsheet workbook first (hub card above)."
: "Workspace token is unreadable — re-paste it in the hub card.",
};
}
const [existing] = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
let token: ReturnType<typeof encryptToken> | null = null;
if (input.token && input.token.trim().length > 0) {
try {
token = encryptToken(input.token.trim());
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to encrypt token",
};
}
}
if (!existing && !token) {
return {
success: false,
error: "Token is required for the first save",
};
}
if (existing && token) {
await db
.update(timeTrackingSmartsheetConfig)
.set({
sheetId: extractSheetId(input.sheetId),
encryptedApiToken: token.cipher,
tokenIv: token.iv,
tokenAuthTag: token.authTag,
columnMapping: input.columnMapping,
syncFrequency: input.syncFrequency,
syncEnabled: input.syncEnabled,
updatedBy: actorId,
})
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
} else if (existing) {
// Keep saved token.
if (existing) {
await db
.update(timeTrackingSmartsheetConfig)
.set({
@@ -279,18 +243,9 @@ export async function saveTTSmartsheetConfig(
})
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
} else {
if (!token) {
return {
success: false,
error: "Token is required for the first save",
};
}
await db.insert(timeTrackingSmartsheetConfig).values({
brandId,
sheetId: extractSheetId(input.sheetId),
encryptedApiToken: token.cipher,
tokenIv: token.iv,
tokenAuthTag: token.authTag,
columnMapping: input.columnMapping,
syncFrequency: input.syncFrequency,
syncEnabled: input.syncEnabled,
@@ -324,22 +279,20 @@ export async function testTTSmartsheetConnection(
let plaintext = "";
if (input?.token && input.token.trim().length > 0) {
plaintext = input.token.trim();
} else if (existing) {
try {
plaintext = decryptToken({
cipher: existing.encryptedApiToken,
iv: existing.tokenIv,
authTag: existing.tokenAuthTag,
});
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to decrypt saved token",
};
} else {
// Cycle 7: token lives on the workspace, not on the per-feature
// config. Decrypt from there.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
if (ws.reason === "no_workspace") {
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card." };
}
if (ws.reason === "connection_disabled") {
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
}
return { success: false, error: ws.error };
}
plaintext = ws.token;
}
if (!sheetId) {
return { success: false, error: "sheetId is required" };
+70 -93
View File
@@ -3,6 +3,11 @@
/**
* Water Log — Smartsheet integration server actions.
*
* Cycle 7: per-feature config no longer holds the API token. The
* encrypted token lives in `smartsheet_workspace` (one row per brand);
* this action reads it via `resolveWorkspaceToken` and only manages
* the per-feature sheet_id + column_mapping + frequency.
*
* Surface for the `/admin/water-log/settings` UI and the entry-insert
* hook in `field.ts`. Permission model mirrors the rest of the water
* log module: only admins with `can_manage_water_log` (or
@@ -10,10 +15,9 @@
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user
* types a new one. An empty token means "keep the saved one".
* - Read actions return `maskedToken` ("••••abcd") and never the
* plaintext.
* - `resolveWorkspaceToken` decrypts it locally for API calls.
* - The masked preview is fetched from the workspace (not the
* per-feature config).
*/
import { desc, eq, and, gte, inArray } from "drizzle-orm";
import { withBrand } from "@/db/client";
@@ -28,7 +32,8 @@ import {
type SmartsheetColumnMapping,
type SmartsheetFrequency,
} from "@/db/schema/water-log";
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
import { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
import { getAdminUser } from "@/lib/admin-permissions";
@@ -63,8 +68,9 @@ export type SmartsheetSyncLogEntry = {
export type SaveSmartsheetConfigInput = {
sheetId: string;
/** Plaintext token. Empty string = keep the saved token. */
token: string;
/** Cycle 7: token is owned by the workspace; this field is kept for
* backward compat with the existing UI but ignored on save. */
token?: string;
syncEnabled: boolean;
syncFrequency: SmartsheetFrequency;
columnMapping: SmartsheetColumnMapping;
@@ -106,6 +112,10 @@ export async function getSmartsheetConfig(
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
const config = rows[0];
// Pull the masked token + hasToken from the workspace (Cycle 7).
// If there's no workspace yet, maskedToken is null and hasToken
// is false — the UI shows "Connect a workbook" in the hub card.
const workspace = await getSmartsheetWorkspace(brandId);
if (!config) {
return {
configured: false,
@@ -113,39 +123,23 @@ export async function getSmartsheetConfig(
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: null,
maskedToken: workspace.maskedToken,
lastSyncAt: null,
lastSyncError: null,
hasToken: false,
hasToken: workspace.hasToken,
updatedAt: null,
};
}
// Decrypt just to compute the masked preview — never return the
// plaintext. If decryption fails (key rotated), we still report
// `hasToken: true` so the admin knows to re-enter it.
let masked: string | null = "••••";
let hasToken = true;
try {
const plain = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
masked = maskToken(plain);
} catch {
hasToken = false;
masked = null;
}
return {
configured: true,
sheetId: config.sheetId,
syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping,
maskedToken: masked,
maskedToken: workspace.maskedToken,
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
lastSyncError: config.lastSyncError,
hasToken,
hasToken: workspace.hasToken,
updatedAt: config.updatedAt.toISOString(),
};
});
@@ -204,12 +198,23 @@ export async function saveSmartsheetConfig(
if (!mappingResult.ok) {
return { success: false, error: mappingResult.error };
}
// The token: empty means "keep existing" (we won't touch the
// encrypted columns). Non-empty means we re-encrypt and replace.
const tokenTrimmed = input.token?.trim() ?? "";
// Cycle 7: the API token is owned by the workspace, not by this
// per-feature config. Refuse to save a sheet mapping if no
// workbook is connected — the customer would have nothing to
// authenticate against at sync time.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok && ws.reason !== "connection_disabled") {
return {
success: false,
error:
ws.reason === "no_workspace"
? "Connect the Smartsheet workbook first (hub card above)."
: "Workspace token is unreadable — re-paste it in the hub card.",
};
}
return withBrand(brandId, async (db) => {
// ── Load existing to know if we have a token already ──
const existingRows = await db
.select()
.from(waterSmartsheetConfig)
@@ -217,20 +222,6 @@ export async function saveSmartsheetConfig(
.limit(1);
const existing = existingRows[0];
if (!existing && tokenTrimmed.length === 0) {
return {
success: false,
error: "API token is required for first-time setup",
};
}
if (existing && tokenTrimmed.length === 0 && !existing.encryptedApiToken) {
return {
success: false,
error: "API token is required (none on file)",
};
}
// ── Build the upsert payload ──
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
@@ -238,34 +229,23 @@ export async function saveSmartsheetConfig(
columnMapping: input.columnMapping,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
};
if (tokenTrimmed.length > 0) {
const enc = encryptToken(tokenTrimmed);
updateValues.encryptedApiToken = enc.cipher;
updateValues.tokenIv = enc.iv;
updateValues.tokenAuthTag = enc.authTag;
}
// Upsert.
const [upserted] = await db
.insert(waterSmartsheetConfig)
.values({
if (existing) {
await db
.update(waterSmartsheetConfig)
.set(updateValues)
.where(eq(waterSmartsheetConfig.brandId, brandId));
} else {
await db.insert(waterSmartsheetConfig).values({
brandId,
sheetId: normalizedSheetId,
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
tokenIv: existing?.tokenIv ?? Buffer.from([]),
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
columnMapping: input.columnMapping,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
createdBy: existing?.createdBy ?? auth.adminUser.email ?? auth.adminUser.id ?? null,
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
})
.onConflictDoUpdate({
target: waterSmartsheetConfig.brandId,
set: updateValues,
})
.returning();
if (!upserted) return { success: false, error: "Save failed" };
});
}
await logAuditEvent({
brandId,
@@ -278,11 +258,9 @@ export async function saveSmartsheetConfig(
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
tokenRotated: tokenTrimmed.length > 0,
},
});
// Re-read for a clean response.
return {
success: true,
config: await getSmartsheetConfig(brandId),
@@ -326,12 +304,17 @@ export async function deleteSmartsheetConfig(
}
/**
* Test the Smartsheet connection without saving.
* Test the Smartsheet connection against a sheet ID, using the
* workspace's saved token.
*
* - If `token` is non-empty, it's used directly (the typical flow
* for the "Test Connection" button on a fresh form).
* - If `token` is empty and a token is saved, decrypt + test that.
* - Returns the sheet name + columns on success.
* - If `token` is non-empty, it's used directly (test the hub card
* before saving).
* - If `token` is empty, the workspace token is decrypted and used.
*
* Cycle 7: the token is no longer saved per-feature — it's the
* workspace's job. This action exists for the per-feature "Test
* Connection" button that re-uses the same UI affordance; the
* underlying token always comes from `smartsheet_workspace`.
*
* Does NOT throw on API errors — returns a structured failure so the
* UI can show a helpful message.
@@ -353,29 +336,23 @@ export async function testSmartsheetConnection(
return { success: false, error: (err as Error).message };
}
// Resolve the token: explicit > saved > error.
// Cycle 7: resolve the token from the workspace, not from the
// per-feature config. If `tokenInput` is provided (the user is
// pasting a fresh token for a Test-Connection-from-form flow),
// honor it. Otherwise decrypt the saved workspace token.
let token = tokenInput?.trim() ?? "";
if (!token) {
const saved = await withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
return rows[0] ?? null;
});
if (!saved) {
return { success: false, error: "No token saved yet — paste one above to test." };
}
try {
token = decryptToken({
cipher: saved.encryptedApiToken,
iv: saved.tokenIv,
authTag: saved.tokenAuthTag,
});
} catch {
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
if (ws.reason === "no_workspace") {
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card above." };
}
if (ws.reason === "connection_disabled") {
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
}
return { success: false, error: ws.error };
}
token = ws.token;
}
try {
+24 -29
View File
@@ -22,6 +22,7 @@ import {
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";
@@ -476,8 +477,10 @@ export default function WaterLogSettingsPage() {
</button>
</form>
{/* Smartsheet Integration — independent of the Admin Portal toggle above.
Self-contained card with its own save / test / activity surface. */}
{/* 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
@@ -489,39 +492,31 @@ export default function WaterLogSettingsPage() {
Smartsheet
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
Push every new water log entry to a Smartsheet sheet. The
brand admin (you) controls the connection, frequency, and
column mapping. The token is encrypted at rest.
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">
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
</div>
</div>
{/* Time Tracking → Smartsheet (Cycle 5). Independent of the
water-log smartsheet card above; uses its own sheet ID,
token, column mapping, and sync queue. Sibling surface in
this same hub view. */}
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 06 Time Tracking Integrations
</p>
<h2
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Smartsheet (Time Tracking)
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
Push every closed clock-out from the time tracking system to a
configured Smartsheet sheet. Same encryption + retry policy as the
water-log smartsheet integration. Config-deferred wire it up
whenever the brand is ready with a sheet ID + API token.
</p>
<div className="mt-6">
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
</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>
);
@@ -4,11 +4,10 @@
* TimeTrackingSmartsheetCard — admin `/admin/water-log/settings`
* card for the Time Tracking → Smartsheet integration (Cycle 5).
*
* Slimmer than the water-log SmartsheetIntegrationCard because the
* user explicitly wants this config-deferred they have no sheet
* ID yet. When they drop one in, they'll get the full column-
* mapping form (Test Connection gates it). Until then, only Sheet
* ID / token / frequency / sync-enabled controls are visible.
* Cycle 7: this card no longer owns the API token. The token lives
* on the workspace (one per brand, owned by the hub card above).
* This card only manages the per-feature mapping: sheet ID + column
* mapping + frequency + sync enabled + recent activity.
*
* Self-contained client component. Takes `brandId` as a prop per
* the brand-isolation convention.
@@ -46,8 +45,6 @@ type Status =
type State = {
config: TTSmartsheetConfigResponse | null;
sheetIdInput: string;
tokenInput: string;
showTokenInput: boolean;
frequency: TTSmartsheetFrequency;
syncEnabled: boolean;
columns: TTColumnOption[];
@@ -69,8 +66,6 @@ type TTColumnOption = {
type Action =
| { type: "SET_CONFIG"; config: TTSmartsheetConfigResponse }
| { type: "SET_SHEET_ID"; value: string }
| { type: "SET_TOKEN"; value: string }
| { type: "SET_SHOW_TOKEN"; value: boolean }
| { type: "SET_FREQUENCY"; value: TTSmartsheetFrequency }
| { type: "SET_SYNC_ENABLED"; value: boolean }
| { type: "SET_COLUMNS"; columns: NonNullable<State["columns"]> }
@@ -103,17 +98,12 @@ function reducer(state: State, action: Action): State {
sheetIdInput: cfg.sheetId ?? "",
frequency: cfg.syncFrequency,
syncEnabled: cfg.syncEnabled,
showTokenInput: !cfg.hasToken,
columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING },
loading: false,
};
}
case "SET_SHEET_ID":
return { ...state, sheetIdInput: action.value };
case "SET_TOKEN":
return { ...state, tokenInput: action.value };
case "SET_SHOW_TOKEN":
return { ...state, showTokenInput: action.value };
case "SET_FREQUENCY":
return { ...state, frequency: action.value };
case "SET_SYNC_ENABLED":
@@ -147,8 +137,6 @@ function initialState(): State {
return {
config: null,
sheetIdInput: "",
tokenInput: "",
showTokenInput: true,
frequency: "hourly",
syncEnabled: false,
columns: [],
@@ -181,12 +169,17 @@ export function TimeTrackingSmartsheetCard({ brandId }: Props) {
void refresh();
}, [refresh]);
// Cycle 7: Test Connection uses the workspace's saved token (no
// per-feature token to pass). The workspace action
// `testSmartsheetWorkspaceConnection` is the hub's button; here we
// call the per-feature test action with no token, which falls
// through to the workspace resolver.
const onTestConnection = useCallback(async () => {
dispatch({ type: "SET_TESTING", value: true });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
const result = await testTTSmartsheetConnection(brandId, {
sheetId: state.sheetIdInput,
token: state.tokenInput || undefined,
// No `token` — the action pulls the saved workspace token.
});
if (!result.success) {
dispatch({
@@ -200,14 +193,16 @@ export function TimeTrackingSmartsheetCard({ brandId }: Props) {
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
dispatch({ type: "SET_TESTING", value: false });
force({});
}, [brandId, state.sheetIdInput, state.tokenInput]);
}, [brandId, state.sheetIdInput]);
const onSave = useCallback(async () => {
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
const result = await saveTTSmartsheetConfig(brandId, {
sheetId: state.sheetIdInput,
token: state.tokenInput,
// Cycle 7: token is owned by the workspace; this field is
// ignored on save but kept for type compat.
token: "",
syncEnabled: state.syncEnabled,
syncFrequency: state.frequency,
columnMapping: state.columnMapping,
@@ -251,6 +246,9 @@ export function TimeTrackingSmartsheetCard({ brandId }: Props) {
);
}
const hasWorkspaceToken = state.config?.hasToken === true;
const maskedToken = state.config?.maskedToken ?? null;
return (
<div
className="rounded-2xl border border-[#e6dfd1] bg-white p-6 shadow-sm"
@@ -259,12 +257,12 @@ export function TimeTrackingSmartsheetCard({ brandId }: Props) {
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-[#1a4d2e]">
Smartsheet (Time Tracking)
Time Tracking Smartsheet
</h2>
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
Push every closed clock-out to a configured Smartsheet sheet.
Same encryption + retry policy as the Water Log smartsheet
integration.
Push every closed clock-out to a sheet inside the connected
Smartsheet workbook. The API token is managed by the hub
card above; this card only configures the sheet mapping.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#1a4d2e]">
@@ -284,8 +282,24 @@ export function TimeTrackingSmartsheetCard({ brandId }: Props) {
</label>
</div>
{/* Workspace token reference */}
<div className="mb-4 rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-xs text-[rgba(60,60,67,0.65)]">
{hasWorkspaceToken && maskedToken ? (
<>
Using workbook token{" "}
<span className="font-mono text-[#1a4d2e]">{maskedToken}</span>{" "}
from the hub card above.
</>
) : (
<>
No Smartsheet workbook is connected yet. Save the hub card
above with an API token before configuring this sheet.
</>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Sheet ID or URL
</label>
@@ -299,44 +313,9 @@ export function TimeTrackingSmartsheetCard({ brandId }: Props) {
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-sheet-id"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
API token
</label>
<div className="flex items-center gap-2">
<input
type={state.showTokenInput ? "text" : "password"}
placeholder={
state.config?.hasToken && !state.showTokenInput
? state.config.maskedToken ?? "••••••••"
: "paste new token here"
}
value={state.tokenInput}
onChange={(e) =>
dispatch({ type: "SET_TOKEN", value: e.target.value })
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-token"
/>
<button
type="button"
className="rounded-md border border-[#e6dfd1] bg-white px-2 py-1 text-xs font-semibold text-[rgba(60,60,67,0.65)] hover:bg-[#fdfaf2]"
onClick={() =>
dispatch({
type: "SET_SHOW_TOKEN",
value: !state.showTokenInput,
})
}
>
{state.showTokenInput ? "Hide" : "Show"}
</button>
</div>
{state.config?.hasToken && !state.tokenInput && (
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
A token is saved. Leave blank to keep it.
</p>
)}
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
Pick the sheet inside the workbook where clock-outs should land.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
@@ -0,0 +1,455 @@
"use client";
/**
* SmartsheetHub — top-level card for `/admin/water-log/settings`.
*
* Cycle 7. Owns the brand-level Smartsheet workbook connection:
* - API token (encrypted at rest in `smartsheet_workspace`)
* - "Test Connection" button — verifies the token against a sheet
* - Connection enable toggle (master kill-switch)
* - Default sheet ID (the "home" tab in the workbook)
* - Last-verified badge
*
* Per-feature sheet mappings (water log + time tracking) live in their
* own cards BELOW this one and inherit the workspace token.
*
* Visual language matches the parent Water Log settings page
* (cream + forest palette, "Field Almanac" style).
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import {
getSmartsheetWorkspace,
saveSmartsheetWorkspace,
testSmartsheetWorkspaceConnection,
disableSmartsheetWorkspace,
} from "@/actions/smartsheet/workspace";
import { formatDateTime } from "@/lib/format-date";
// ── State ──────────────────────────────────────────────────────────────────
type State = {
loading: boolean;
saving: boolean;
message: { type: "success" | "error"; text: string } | null;
workspace: Awaited<ReturnType<typeof getSmartsheetWorkspace>> | null;
// Editable form fields:
tokenInput: string;
defaultSheetIdInput: string;
showToken: boolean;
connectionEnabled: boolean;
};
type Action =
| { type: "load"; workspace: State["workspace"] }
| { type: "loading"; loading: boolean }
| { type: "saving"; saving: boolean }
| { type: "token"; value: string }
| { type: "showToken"; value: boolean }
| { type: "defaultSheet"; value: string }
| { type: "connection"; value: boolean }
| { type: "message"; message: State["message"] }
| { type: "reset-message" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "load":
return {
...state,
workspace: action.workspace,
connectionEnabled: action.workspace?.connectionEnabled ?? true,
defaultSheetIdInput: action.workspace?.defaultSheetId ?? "",
};
case "loading":
return { ...state, loading: action.loading };
case "saving":
return { ...state, saving: action.saving };
case "token":
return { ...state, tokenInput: action.value };
case "showToken":
return { ...state, showToken: action.value };
case "defaultSheet":
return { ...state, defaultSheetIdInput: action.value };
case "connection":
return { ...state, connectionEnabled: action.value };
case "message":
return { ...state, message: action.message };
case "reset-message":
return { ...state, message: null };
}
}
const INITIAL_STATE: State = {
loading: true,
saving: false,
message: null,
workspace: null,
tokenInput: "",
defaultSheetIdInput: "",
showToken: false,
connectionEnabled: true,
};
// ── Component ─────────────────────────────────────────────────────────────
export function SmartsheetHub({ brandId }: { brandId: string }) {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const [testResult, setTestResult] = useState<
| { state: "idle" }
| { state: "loading" }
| { state: "ok"; sheetName: string; columnCount: number }
| { state: "error"; message: string }
>({ state: "idle" });
// ── Load on mount + brandId change ──
useEffect(() => {
let cancelled = false;
void (async () => {
dispatch({ type: "loading", loading: true });
try {
const ws = await getSmartsheetWorkspace(brandId);
if (!cancelled) {
dispatch({ type: "load", workspace: ws });
dispatch({ type: "loading", loading: false });
}
} catch (err) {
if (!cancelled) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Failed to load workspace",
},
});
dispatch({ type: "loading", loading: false });
}
}
})();
return () => {
cancelled = true;
};
}, [brandId]);
// ── Save ──
const handleSave = useCallback(async () => {
dispatch({ type: "saving", saving: true });
dispatch({ type: "reset-message" });
try {
const defaultSheetId =
state.defaultSheetIdInput.trim().length > 0
? state.defaultSheetIdInput.trim()
: null;
const result = await saveSmartsheetWorkspace(brandId, {
token: state.tokenInput,
defaultSheetId,
connectionEnabled: state.connectionEnabled,
});
if (!result.success) {
dispatch({
type: "message",
message: { type: "error", text: result.error },
});
return;
}
dispatch({ type: "load", workspace: result.workspace });
// Clear the token input on success — never leave it in the form.
dispatch({ type: "token", value: "" });
dispatch({ type: "showToken", value: false });
dispatch({
type: "message",
message: {
type: "success",
text: state.tokenInput.length > 0
? "Workspace connected."
: "Workspace settings saved.",
},
});
} catch (err) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Save failed",
},
});
} finally {
dispatch({ type: "saving", saving: false });
}
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.connectionEnabled]);
// ── Test connection ──
const handleTest = useCallback(async () => {
setTestResult({ state: "loading" });
const sheetIdInput =
state.defaultSheetIdInput.trim().length > 0
? state.defaultSheetIdInput.trim()
: state.workspace?.defaultSheetId ?? "";
if (!sheetIdInput) {
setTestResult({
state: "error",
message: "Enter a sheet ID or URL above to test against.",
});
return;
}
try {
const result = await testSmartsheetWorkspaceConnection(
brandId,
sheetIdInput,
state.tokenInput,
);
if (result.success) {
setTestResult({
state: "ok",
sheetName: result.sheetName,
columnCount: result.columnCount,
});
} else {
setTestResult({ state: "error", message: result.error });
}
} catch (err) {
setTestResult({
state: "error",
message: err instanceof Error ? err.message : "Test failed",
});
}
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.workspace?.defaultSheetId]);
// ── Disable (no-confirm shortcut) ──
const handleDisable = useCallback(async () => {
dispatch({ type: "saving", saving: true });
try {
await disableSmartsheetWorkspace(brandId);
const ws = await getSmartsheetWorkspace(brandId);
dispatch({ type: "load", workspace: ws });
dispatch({
type: "message",
message: { type: "success", text: "Connection disabled. Sheet mappings preserved." },
});
} catch (err) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Disable failed",
},
});
} finally {
dispatch({ type: "saving", saving: false });
}
}, [brandId]);
if (state.loading) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<p className="text-sm text-[#5a5d5a]">Loading workspace</p>
</div>
);
}
const isConnected = state.workspace?.configured === true;
const hasToken = state.workspace?.hasToken === true;
const maskedToken = state.workspace?.maskedToken ?? null;
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3
className="text-lg font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Workbook connection
</h3>
<p className="mt-1 text-sm text-[#5a5d5a]">
One Smartsheet API token powers every feature below (water log, time
tracking, future payroll). Per-feature cards just pick which sheet
inside the workbook to write to.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${
isConnected && state.connectionEnabled
? "bg-[#dfe9d4] text-[#1a4d2e]"
: isConnected
? "bg-[#f1e8d4] text-[#8a6b3b]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isConnected && state.connectionEnabled
? "bg-[#1a4d2e]"
: isConnected
? "bg-[#8a6b3b]"
: "bg-[#a4452b]"
}`}
/>
{isConnected
? state.connectionEnabled
? "Connected"
: "Paused"
: "Not connected"}
</span>
</div>
</div>
{/* ── Last verified ── */}
{isConnected && state.workspace?.lastTestAt && (
<p className="mt-3 text-xs text-[#5a5d5a]">
Last verified: {formatDateTime(new Date(state.workspace.lastTestAt))}
{state.workspace.lastTestError && (
<span className="ml-2 text-[#a4452b]">
· {state.workspace.lastTestError.slice(0, 80)}
</span>
)}
</p>
)}
{/* ── Form ── */}
<div className="mt-5 space-y-4">
<div>
<label className="block text-sm font-medium text-[#1a4d2e]">
API token
</label>
<div className="mt-1 flex items-center gap-2">
<input
type={state.showToken ? "text" : "password"}
autoComplete="off"
spellCheck={false}
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
placeholder={
hasToken && maskedToken
? `Saved: ${maskedToken} — paste to rotate`
: "Paste your Smartsheet API token"
}
value={state.tokenInput}
onChange={(e) =>
dispatch({ type: "token", value: e.target.value })
}
data-test="smartsheet-workspace-token"
/>
<button
type="button"
className="rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#5a5d5a] hover:bg-[#fdfaf2]"
onClick={() =>
dispatch({ type: "showToken", value: !state.showToken })
}
>
{state.showToken ? "Hide" : "Show"}
</button>
</div>
<p className="mt-1 text-xs text-[#5a5d5a]">
Encrypted at rest with AES-256-GCM. Never returned to the browser
after save.
</p>
</div>
<div>
<label className="block text-sm font-medium text-[#1a4d2e]">
Default sheet ID (optional)
</label>
<input
type="text"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
placeholder="e.g. 8309876543210123 (or paste a Smartsheet URL)"
value={state.defaultSheetIdInput}
onChange={(e) =>
dispatch({ type: "defaultSheet", value: e.target.value })
}
data-test="smartsheet-workspace-default-sheet"
/>
<p className="mt-1 text-xs text-[#5a5d5a]">
The &ldquo;home&rdquo; tab when the customer opens the workbook.
Per-feature cards below pick their own sheets independently.
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#d4d9d3] text-[#1a4d2e] focus:ring-[#1a4d2e]"
checked={state.connectionEnabled}
onChange={(e) =>
dispatch({ type: "connection", value: e.target.checked })
}
data-test="smartsheet-workspace-enabled"
/>
Connection enabled
</label>
<span className="text-xs text-[#5a5d5a]">
(Master switch pauses all per-feature syncs without losing mappings)
</span>
</div>
</div>
{/* ── Actions ── */}
<div className="mt-5 flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-lg bg-[#1a4d2e] px-4 py-2 text-sm font-medium text-white hover:bg-[#14432a] disabled:opacity-60"
onClick={handleSave}
disabled={state.saving}
data-test="smartsheet-workspace-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:opacity-60"
onClick={handleTest}
disabled={testResult.state === "loading"}
data-test="smartsheet-workspace-test"
>
{testResult.state === "loading" ? "Testing…" : "Test connection"}
</button>
{isConnected && state.connectionEnabled && (
<button
type="button"
className="rounded-lg border border-[#d4d9d3] bg-white px-4 py-2 text-sm font-medium text-[#8a6b3b] hover:bg-[#fdfaf2] disabled:opacity-60"
onClick={handleDisable}
disabled={state.saving}
>
Pause connection
</button>
)}
</div>
{/* ── Test result ── */}
{testResult.state === "ok" && (
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#dfe9d4] bg-[#dfe9d4] p-3">
<p className="text-sm font-medium text-[#1a4d2e]">
Connected to &ldquo;{testResult.sheetName}&rdquo;
</p>
<p className="mt-1 text-xs text-[#5a5d5a]">
{testResult.columnCount} columns visible. The test uses the
{state.tokenInput.length > 0 ? " pasted" : " saved"} token.
</p>
</div>
)}
{testResult.state === "error" && (
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#f0e1d8] bg-[#f0e1d8] p-3">
<p className="text-sm font-medium text-[#a4452b]">
{testResult.message}
</p>
</div>
)}
{/* ── Save message ── */}
{state.message && (
<div
className={`smartsheet-fade-in mt-4 rounded-lg p-3 text-sm ${
state.message.type === "success"
? "bg-[#dfe9d4] text-[#1a4d2e]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
{state.message.text}
</div>
)}
</div>
);
}
export default SmartsheetHub;
File diff suppressed because it is too large Load Diff
+25 -11
View File
@@ -39,7 +39,7 @@ import {
waterSmartsheetSyncLog,
type SmartsheetColumnMapping,
} from "@/db/schema/water-log";
import { decryptToken } from "@/lib/crypto";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import {
addRow,
findRowByColumn,
@@ -159,24 +159,38 @@ export async function syncEntryToSmartsheet(
);
}
// ── 3. Decrypt token + build cells ──
let token: string;
try {
token = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
} catch (err) {
// ── 3. Resolve token from the workspace (Cycle 7) ──
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
// "no_workspace" + "decryption_failed" are real failures — log
// them, bump attempts, and back off. "connection_disabled" is
// an intentional admin pause — skip cleanly, don't retry, don't
// pollute the activity log with "disabled" failures.
if (ws.reason === "connection_disabled") {
return finalizeSkip(
db,
brandId,
entryId,
queue?.id ?? null,
"workspace paused",
queue?.smartsheetRowId ?? null,
start,
);
}
const reason =
ws.reason === "no_workspace"
? "Smartsheet workbook not connected"
: `workspace token unreadable: ${ws.error}`;
return recordFailure(
db,
brandId,
entryId,
queue?.attempts ?? 0,
`decryption failed (key rotated or corrupt): ${sanitizeError(err)}`,
reason,
start,
);
}
const token = ws.token;
const cells = buildCells(
config.columnMapping,
+30 -6
View File
@@ -42,7 +42,7 @@ import {
timeTrackingSmartsheetSyncLog,
type TTSmartsheetColumnMapping,
} from "@/db/schema/time-tracking";
import { decryptToken } from "@/lib/crypto";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import {
addRow,
findRowByColumn,
@@ -248,11 +248,35 @@ export async function syncLogToSmartsheet(
return result;
}
const token = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
// "connection_disabled" is an intentional admin pause — skip
// cleanly, don't retry, don't bump attempts or log as a failure.
// (Sync services treat each reason differently per the Cycle 7
// design doc.)
if (ws.reason === "connection_disabled") {
const result: TTSyncResult = {
status: "skipped",
reason: "workspace paused",
smartsheetRowId: queueRow?.smartsheetRowId ?? null,
durationMs: Date.now() - start,
};
return result;
}
const reason =
ws.reason === "no_workspace"
? "Smartsheet workbook not connected"
: `workspace token unreadable: ${ws.error}`;
const result: TTSyncResult = {
status: "failed",
error: reason,
attempts: 0,
durationMs: Date.now() - start,
retryAt: new Date(),
};
return result;
}
const token = ws.token;
const values = {
logId: logRow.log.id,
+53
View File
@@ -0,0 +1,53 @@
/**
* Workspace token resolver — server-only helper.
*
* Cycle 7 — extracted from `src/actions/smartsheet/workspace.ts` to
* avoid a `"use server"` leak: every async export of a "use server"
* file is reachable as a server action RPC, and we don't want this
* function callable from the client (it returns the plaintext API
* token). Living in a regular module means it's only importable from
* other server code (`smartsheet-sync.ts`, `time-tracking-smartsheet-
* sync.ts`) — the client surface is in the workspace actions file,
* which gates with `requireManageSettings` and never returns
* plaintext.
*/
import "server-only";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
import { decryptToken } from "@/lib/crypto";
export type ResolvedWorkspaceToken =
| { ok: true; token: string }
| { ok: false; reason: "no_workspace" }
| { ok: false; reason: "connection_disabled" }
| { ok: false; reason: "decryption_failed"; error: string };
export async function resolveWorkspaceToken(
brandId: string,
): Promise<ResolvedWorkspaceToken> {
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const ws = rows[0];
if (!ws) return { ok: false, reason: "no_workspace" };
if (!ws.connectionEnabled) return { ok: false, reason: "connection_disabled" };
try {
const token = decryptToken({
cipher: ws.encryptedApiToken,
iv: ws.tokenIv,
authTag: ws.tokenAuthTag,
});
return { ok: true, token };
} catch (err) {
return {
ok: false,
reason: "decryption_failed",
error: err instanceof Error ? err.message : String(err),
};
}
});
}