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