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:
@@ -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
Reference in New Issue
Block a user