ad4d3c9976
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).
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
"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.
|