diff --git a/db/migrations/0096_smartsheet_workbook_hub.sql b/db/migrations/0096_smartsheet_workbook_hub.sql new file mode 100644 index 0000000..6d189d7 --- /dev/null +++ b/db/migrations/0096_smartsheet_workbook_hub.sql @@ -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; \ No newline at end of file diff --git a/db/schema/smartsheet-workspace.ts b/db/schema/smartsheet-workspace.ts new file mode 100644 index 0000000..9281df1 --- /dev/null +++ b/db/schema/smartsheet-workspace.ts @@ -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; \ No newline at end of file diff --git a/db/schema/time-tracking.ts b/db/schema/time-tracking.ts index a255f04..c0ffa36 100644 --- a/db/schema/time-tracking.ts +++ b/db/schema/time-tracking.ts @@ -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() .notNull(), diff --git a/db/schema/water-log.ts b/db/schema/water-log.ts index ca1bed5..1cef92f 100644 --- a/db/schema/water-log.ts +++ b/db/schema/water-log.ts @@ -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() .notNull(), diff --git a/src/actions/smartsheet/workspace.ts b/src/actions/smartsheet/workspace.ts new file mode 100644 index 0000000..e22bab4 --- /dev/null +++ b/src/actions/smartsheet/workspace.ts @@ -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>> } | { 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 { + 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 = { + 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 { + 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. \ No newline at end of file diff --git a/src/actions/time-tracking/smartsheet.ts b/src/actions/time-tracking/smartsheet.ts index 8137757..779bf47 100644 --- a/src/actions/time-tracking/smartsheet.ts +++ b/src/actions/time-tracking/smartsheet.ts @@ -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 | 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" }; diff --git a/src/actions/water-log/smartsheet.ts b/src/actions/water-log/smartsheet.ts index a1adcae..5f81ac9 100644 --- a/src/actions/water-log/smartsheet.ts +++ b/src/actions/water-log/smartsheet.ts @@ -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 = { 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 { diff --git a/src/app/admin/water-log/settings/page.tsx b/src/app/admin/water-log/settings/page.tsx index 305dacb..c995ed1 100644 --- a/src/app/admin/water-log/settings/page.tsx +++ b/src/app/admin/water-log/settings/page.tsx @@ -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() { - {/* 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. */}

§ 05 — Integrations @@ -489,39 +492,31 @@ export default function WaterLogSettingsPage() { Smartsheet

- 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.

- +
- {/* 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. */} -
-

- § 06 — Time Tracking Integrations -

-

- Smartsheet (Time Tracking) -

-

- 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. -

-
- -
+ {/* Water Log sheet mapping — uses the workbook token from the + hub card above. Configures which sheet inside the workbook + receives water-log entries. */} +
+
+ + {/* Time Tracking sheet mapping — also uses the workbook token. + Configures which sheet inside the workbook receives + clock-out rows. */} +
+ +
+ + {/* § 06 removed in Cycle 7 — Time Tracking sheet mapping is now + a sibling card under § 05 above (uses the workbook token). */}
); diff --git a/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx b/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx index fe6ae56..73b4e41 100644 --- a/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx +++ b/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx @@ -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 } @@ -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 (

- Smartsheet (Time Tracking) + Time Tracking → Smartsheet

- 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.

+ {/* Workspace token reference */} +
+ {hasWorkspaceToken && maskedToken ? ( + <> + Using workbook token{" "} + {maskedToken}{" "} + from the hub card above. + + ) : ( + <> + ⚠ No Smartsheet workbook is connected yet. Save the hub card + above with an API token before configuring this sheet. + + )} +
+
-
+
@@ -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" /> -
-
- -
- - 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" - /> - -
- {state.config?.hasToken && !state.tokenInput && ( -

- A token is saved. Leave blank to keep it. -

- )} +

+ Pick the sheet inside the workbook where clock-outs should land. +