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,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.
|
||||
Reference in New Issue
Block a user