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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]">
|
||||
@@ -549,4 +528,4 @@ function RecentActivity({
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeTrackingSmartsheetCard;
|
||||
export default TimeTrackingSmartsheetCard;
|
||||
@@ -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 “home” 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 “{testResult.sheetName}”
|
||||
</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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user