From ce3a948b31fcae95135bf3377772b6718a2d5de6 Mon Sep 17 00:00:00 2001
From: "Roger Tuan (DatoCMS)"
Date: Tue, 30 Jun 2026 19:23:50 -0700
Subject: [PATCH 1/5] docs(automatic-backups): config screen wizard redesign
spec
Design spec for reworking the Automatic Environment Backups config screen into a gated, per-step wizard with a single source of truth (saved plugin parameters).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../specs/2026-06-30-config-wizard-design.md | 226 ++++++++++++++++++
1 file changed, 226 insertions(+)
create mode 100644 automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md
diff --git a/automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md b/automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md
new file mode 100644
index 00000000..c716cb52
--- /dev/null
+++ b/automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md
@@ -0,0 +1,226 @@
+# Config Screen Wizard Redesign — Design Spec
+
+**Plugin:** `automatic-environment-backups`
+**File under redesign:** `src/entrypoints/ConfigScreen.tsx` (currently ~1,863 lines)
+**Date:** 2026-06-30
+**Status:** Approved design, pending spec review → writing-plans
+
+---
+
+## 1. Problem
+
+The current config screen is one monolithic component that presents three flat cards
+(Lambda setup, Backup schedule, Backup overview) with no ordering or per-section status.
+This produced the issues in the support ticket:
+
+1. The auth secret has **three out-of-sync representations** — the input field
+ (`lambdaAuthSecretInput`), an in-memory snapshot (`savedFormValues.lambdaAuthSecret`),
+ and the persisted plugin parameter. "Connect" reads the input; the overview and
+ "Backup now" read the snapshot. Editing the secret without a full Save leaves them
+ desynced → the plugin sends the wrong/old token → HTTP 401.
+2. Connection errors surface only as a tiny status dot at the top; the actual error text
+ is buried several screens down under "Backup overview".
+3. New users have no guided order (set secret → deploy → connect → schedule), and no
+ per-section success/failure feedback.
+4. Returning users can't tell at a glance whether their setup is still healthy.
+
+## 2. Goals / Non-goals
+
+**Goals**
+- A guided, ordered **gated accordion** of setup steps, each with its own status and save.
+- **Single source of truth:** the saved plugin parameters, re-read from `ctx`. No React
+ state snapshot that can lag/race.
+- **Explicit per-step saves.** Every user change is committed by an explicit step action
+ before anything acts on it.
+- Clear per-step status: **Current step → OK / Error**.
+- A **Status overview** checklist that redundantly surfaces every OK/error condition.
+- Auto-generate a strong secret on fresh install; preserve any existing secret.
+- Preserve existing users' working setups (persisted-parameter schema unchanged).
+
+**Non-goals**
+- No change to the persisted plugin-parameter schema (backward compatibility).
+- No changes to the external lambda repo (`datocms-backups-scheduled-function`).
+- No change to backup mechanics (cadences, cron, environment creation).
+- **No new test infrastructure** (no jsdom/testing-library). Reuse existing `vitest`.
+
+## 3. Status vocabulary
+
+Setup steps (1–3) each resolve to exactly one of:
+
+| Status | Label | Meaning / display |
+|--------|-------|-------------------|
+| `ok` | **OK** (green) | Saved params for this step are valid (and, for Connect, the live ping is healthy). Collapses to a one-line summary with **[Edit]**. |
+| `current` | **Current step** (neutral, highlighted, expanded) | The first step that is not `ok`. Being worked on; no error yet. |
+| `error` | **Error** (red, expanded) | The current step whose last save/test failed, or whose live validation now fails (e.g. secret rotated on the provider). |
+| `disabled` | *(no label, grayed)* | A step after the current one. Visible but non-interactive. No "locked" chrome. |
+
+Flow: the `current` step transitions to `ok` (next step becomes `current`) or `error`
+(stays the focus until resolved). **At most one** setup step is `current`/`error` at a
+time; earlier steps are `ok`, later steps `disabled`. When all three are `ok` (fully
+configured), no step is `current` — every step shows its collapsed **OK** summary.
+
+Section 4 (**Status overview**) is **not** a gated step — it is an always-visible,
+read-only checklist that reflects live state and mirrors per-step errors.
+
+## 4. Source of truth & save model
+
+- **`ctx.plugin.attributes.parameters` is authoritative.** All status derivation and all
+ actions read from it (via typed getters), re-reading from `ctx` at render/action time.
+- **Remove `savedFormValues`.** The in-memory snapshot that races today is deleted.
+- **Input fields are ephemeral edit-state** (`secretInput`, `urlInput`, cadence selection).
+ They exist only so the user can type before committing.
+- **Every user change requires an explicit per-step save.** No lambda-facing action ever
+ reads unsaved input. A step action is atomic: *persist the input → then act on the
+ just-persisted value.* Within a save+act handler, the code acts on the value it just
+ wrote (equal to what was persisted), never on a lagging read.
+- `persistPluginParameters` keeps its authoritative CMA re-read + queued merge, so
+ concurrent saves serialize and never clobber unrelated keys.
+
+This structurally eliminates the desync class of bug (#1): there is only one place a
+value lives (the params), and only one way to change it (an explicit save).
+
+## 5. Architecture / file decomposition
+
+Split the monolith into small, single-purpose units. Pure modules are unit-testable with
+the existing `vitest` (no DOM).
+
+```
+src/entrypoints/ConfigScreen.tsx orchestrator: reads ctx params, derives step
+ statuses, renders the accordion + Status overview
+src/config/
+ pluginParams.ts (pure) typed getters/setters over ctx params
+ (secret, deploymentURL, connection, schedule, debug)
+ deriveStepStatuses.ts (pure) (params + live connection) → { step1, step2, step3 }
+ statuses + which step is current + checklist items
+ generateAuthSecret.ts (pure) 128-bit crypto → 32-char lowercase hex
+ useBackupsConfig.ts (hook) shared edit-state, per-step save handlers,
+ mount health ping, overview + env-list loading
+ StepSection.tsx accordion chrome: header, status badge, expand/
+ collapse, disabled state, one-line summary + [Edit]
+ StepSecret.tsx Step 1
+ StepConnect.tsx Step 2
+ StepSchedule.tsx Step 3
+ StatusOverview.tsx Section 4 (checklist)
+ StatusBox.tsx neutral / success / error panel (reused in steps)
+ AdvancedSettings.tsx debug toggle (collapsible, persists on change)
+```
+
+Reused unchanged: `verifyLambdaHealth`, `lambdaAuth`, `lambdaHttp`,
+`fetchLambdaBackupStatus`, `triggerLambdaBackupNow`, `backupSchedule`,
+`buildBackupOverviewRows`, `deployProviders`, `pluginParameterMerging`, `debugLogger`.
+
+## 6. Step specifications
+
+Each setup step shows a one-sentence "what & why", its inputs, a single primary save
+action, and a `StatusBox` reflecting its result.
+
+### Step 1 — Auth secret & deploy
+> *Create a shared secret the plugin and your deployed function use to authenticate with each other, then deploy the scheduler.*
+
+- **Secret field** (`secretInput`) + **[Generate]** + **[Copy]**.
+ - Fresh install (no saved secret): auto-generate a value into the field on first load
+ (unsaved). Existing saved secret: load it as-is.
+- **[Save secret]** — primary. Persists `lambdaAuthSecret`. Marks Step 1 `ok`.
+- After the secret is saved, reveal **[Deploy to ▾]** (Vercel / Netlify / Cloudflare —
+ unchanged menu) and a callout: *"Paste this value as `DATOCMS_BACKUPS_SHARED_SECRET`
+ on your provider, then come back with the deployed URL."* Deploy is **disabled until the
+ secret is saved** and simply opens the provider tab (no auto-injection — providers can't
+ receive the value programmatically).
+- If a healthy connection already exists and the secret field is edited: inline warning
+ *"Changing this means updating `DATOCMS_BACKUPS_SHARED_SECRET` on your deployment and
+ redeploying, or the connection will fail."* Saving a changed secret re-gates Step 2.
+- **Complete when:** non-empty `lambdaAuthSecret` is saved.
+
+### Step 2 — Connect & test connection
+> *Tell the plugin where your function is deployed and verify it responds and authenticates.*
+
+- **URL field** (`urlInput`) + **[Save & test connection]**.
+- Action: persist `deploymentURL` (+ legacy `netlifyURL`/`vercelURL` for compat) → run
+ `verifyLambdaHealth` using the **saved** secret + saved URL → persist the resulting
+ `lambdaConnection` + `connectionValidationMode`.
+- **StatusBox** shows *testing… / ✓ Connected / ✕ Failed* **in the step**, with the exact
+ reason from the existing connection state machine (`errorCode`, `errorMessage`,
+ `httpStatus`, response snippet) and remediation. Example (401):
+ *"Auth failed — the secret here doesn't match `DATOCMS_BACKUPS_SHARED_SECRET` on your
+ deployment. Update one so they match, then redeploy if you changed the provider."*
+- **Complete when:** `deploymentURL` saved **and** live ping `connected`. **Error when:**
+ live ping fails.
+
+### Step 3 — Backup cadence
+> *Choose how often backups run. The scheduler runs once daily and creates the sandbox backups you enable.*
+
+- Cadence switches (Daily / Weekly / Bi-weekly / Monthly) + **[Save & continue]**.
+- Action: persist `backupSchedule` → run `ensureBackupsExistForCadences` with a
+ *"Creating initial backups…"* progress state; created/failed environments reported.
+- **Complete when:** `backupSchedule.enabledCadences` has ≥1.
+
+### Section 4 — Status overview (always visible, read-only)
+> *Everything at a glance — nothing else to do here.*
+
+A checklist reflecting live state, redundant with per-step errors on purpose:
+
+- ✓/✗ Auth secret set *(warn if it's still the example default)*
+- ✓/✗ Function reachable & authenticating *(mirrors Step 2 error verbatim if failing)*
+- ✓/✗ Backup cadence configured — lists enabled cadences
+- ✓/pending Backup environments created — e.g. "2 of 2 created" / "pending"
+- Per-cadence rows: last backup / next backup / environment, with **[Backup now]** (kept).
+- Summary banner: **"✓ Configured and ready — backups run daily at 02:05 UTC. You can
+ leave this screen."** or **"Needs attention — see the highlighted step above."**
+
+## 7. Existing-user & resume behavior
+
+Derived entirely from saved params + the mount ping (which always re-validates on load):
+
+- **Fully configured & healthy:** all setup steps `ok` (collapsed summaries), Status
+ overview all green. Any step expandable via **[Edit]**.
+- **Broke since last visit** (the ticket case): mount ping fails → Step 2 becomes `error`,
+ auto-expanded with the exact reason; Status overview shows the red item too.
+- **Partially configured:** first non-`ok` step is `current`/auto-expanded; earlier steps
+ `ok`, later steps `disabled`.
+- Editing an earlier step re-gates later steps (e.g. changing the secret un-verifies the
+ connection → Step 2 must be re-tested).
+
+## 8. Persistence & backward compatibility
+
+- Persisted-parameter schema is **unchanged**: `deploymentURL`, `netlifyURL`, `vercelURL`,
+ `lambdaConnection`, `connectionValidationMode`, `lambdaAuthSecret`, `backupSchedule`,
+ `debug`. Existing installs load and work without migration.
+- `netlifyURL`/`vercelURL` continue to be written in lockstep with `deploymentURL` (harmless,
+ preserves any external readers). Reads prefer `deploymentURL`.
+- `debug` persists immediately on toggle (Advanced settings), consistent with per-step saves.
+
+## 9. Secret generation
+
+- 16 random bytes from `crypto.getRandomValues` → lowercase hex → **32 characters**,
+ charset `[0-9a-f]` (inherently URL-safe).
+- 128 bits of entropy; short and well under every provider's env-var value limit
+ (Vercel ~64 KB, Netlify/Cloudflare ~5 KB per value).
+- `[Generate]` replaces the field value (unsaved until [Save secret]). Regenerating while
+ connected triggers the Step 1 warning (see §6).
+
+## 10. Testing
+
+Use the **existing** `vitest` setup only. New pure modules get unit tests:
+
+- `generateAuthSecret`: length = 32, charset `[0-9a-f]`, high-probability uniqueness across
+ calls, uses `crypto.getRandomValues`.
+- `deriveStepStatuses`: every permutation — fresh, secret-only, connected, connect-error,
+ schedule set, fully configured, and "broke since last visit" — asserting the correct
+ `current`/`ok`/`error`/`disabled` per step and the checklist items.
+- A regression guard asserting a per-step save updates the param that all reads consume
+ (encodes the desync fix — reads come from params, not a snapshot).
+
+Component/accordion interaction tests are **out of scope** for now (would need a jsdom +
+`@testing-library/react` harness we are deliberately not adding yet).
+
+## 11. Risks / open items
+
+- **DatoCMS `ctx` staleness within a handler:** immediately after `updatePluginParameters`,
+ the in-scope `ctx` may not reflect the write until re-render. Mitigation: within a
+ save+act handler, act on the value just persisted (in hand); rendering/derivation reads
+ from the re-rendered `ctx`. `persistPluginParameters` already re-reads authoritative
+ params via the CMA client before merging.
+- **StrictMode / run-once effects:** the mount health ping keeps the existing run-once
+ guard from the loop fix; the decomposition must preserve it.
+- **"Still the default secret" detection:** the Status-overview warning compares the saved
+ secret against the known example default to nudge rotation; purely advisory.
From 6c07f8792c1f2eb3ea1bfcc231f4fc8c1ff4fd0f Mon Sep 17 00:00:00 2001
From: "Roger Tuan (DatoCMS)"
Date: Tue, 30 Jun 2026 19:24:04 -0700
Subject: [PATCH 2/5] feat(automatic-backups): rework config screen into a
gated step wizard
Split the ~1.8k-line ConfigScreen monolith into a thin orchestrator plus
focused single-purpose units under src/config/:
- Pure, unit-tested modules: generateAuthSecret, pluginParams (typed
source-of-truth readers), deriveStepStatuses + buildStatusChecklist.
- useBackupsConfig hook holding all edit-state, the queued authoritative
persister, per-step save+act handlers, and the run-once mount ping.
- Accordion chrome (StepSection, StatusBox) and per-step components
(StepSecret, StepConnect, StepSchedule, StatusOverview, AdvancedSettings).
Behavior changes:
- Saved plugin parameters are the single source of truth; the savedFormValues
React snapshot is removed. This eliminates the auth-secret desync that made
the plugin send the wrong/default token (HTTP 401).
- Explicit per-step saves; no global Save button.
- Fresh installs auto-generate a 128-bit hex secret; existing secrets are kept.
- Preserves the run-once, StrictMode-safe mount health ping, fixing the
config-screen infinite request loop.
- Persisted-parameter schema is unchanged, so existing installs keep working.
Adds 25 unit tests for the pure modules (60 total, all green). tsc + vitest +
vite build all pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/config/AdvancedSettings.tsx | 53 +
.../src/config/StatusBox.tsx | 78 +
.../src/config/StatusOverview.tsx | 225 ++
.../src/config/StepConnect.tsx | 115 +
.../src/config/StepSchedule.tsx | 61 +
.../src/config/StepSecret.tsx | 229 ++
.../src/config/StepSection.tsx | 210 ++
.../src/config/deriveStepStatuses.test.ts | 166 ++
.../src/config/deriveStepStatuses.ts | 194 ++
.../src/config/generateAuthSecret.test.ts | 22 +
.../src/config/generateAuthSecret.ts | 21 +
.../src/config/pluginParams.test.ts | 163 ++
.../src/config/pluginParams.ts | 99 +
.../src/config/useBackupsConfig.ts | 984 +++++++++
.../src/entrypoints/ConfigScreen.tsx | 1945 +----------------
15 files changed, 2721 insertions(+), 1844 deletions(-)
create mode 100644 automatic-environment-backups/src/config/AdvancedSettings.tsx
create mode 100644 automatic-environment-backups/src/config/StatusBox.tsx
create mode 100644 automatic-environment-backups/src/config/StatusOverview.tsx
create mode 100644 automatic-environment-backups/src/config/StepConnect.tsx
create mode 100644 automatic-environment-backups/src/config/StepSchedule.tsx
create mode 100644 automatic-environment-backups/src/config/StepSecret.tsx
create mode 100644 automatic-environment-backups/src/config/StepSection.tsx
create mode 100644 automatic-environment-backups/src/config/deriveStepStatuses.test.ts
create mode 100644 automatic-environment-backups/src/config/deriveStepStatuses.ts
create mode 100644 automatic-environment-backups/src/config/generateAuthSecret.test.ts
create mode 100644 automatic-environment-backups/src/config/generateAuthSecret.ts
create mode 100644 automatic-environment-backups/src/config/pluginParams.test.ts
create mode 100644 automatic-environment-backups/src/config/pluginParams.ts
create mode 100644 automatic-environment-backups/src/config/useBackupsConfig.ts
diff --git a/automatic-environment-backups/src/config/AdvancedSettings.tsx b/automatic-environment-backups/src/config/AdvancedSettings.tsx
new file mode 100644
index 00000000..8d0c115e
--- /dev/null
+++ b/automatic-environment-backups/src/config/AdvancedSettings.tsx
@@ -0,0 +1,53 @@
+import { Form, Section, SwitchField } from 'datocms-react-ui';
+import { type CSSProperties, useState } from 'react';
+
+const switchNoHintGapStyle = {
+ '--spacing-s': '0',
+ marginBottom: '0.25rem',
+} as CSSProperties;
+
+/**
+ * Collapsible advanced settings. The debug toggle persists immediately on
+ * change (consistent with the per-step save model — no global Save button).
+ */
+export const AdvancedSettings = ({
+ debugEnabled,
+ onToggleDebug,
+}: {
+ debugEnabled: boolean;
+ onToggleDebug: (enabled: boolean) => void;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ );
+};
diff --git a/automatic-environment-backups/src/config/StatusBox.tsx b/automatic-environment-backups/src/config/StatusBox.tsx
new file mode 100644
index 00000000..036d7569
--- /dev/null
+++ b/automatic-environment-backups/src/config/StatusBox.tsx
@@ -0,0 +1,78 @@
+import type { CSSProperties, ReactNode } from 'react';
+
+/** Visual tone of a {@link StatusBox}, mapped to the dashboard soft-color tokens. */
+export type StatusBoxVariant = 'neutral' | 'success' | 'error' | 'warning';
+
+type StatusBoxProps = {
+ variant: StatusBoxVariant;
+ title?: ReactNode;
+ children?: ReactNode;
+ style?: CSSProperties;
+};
+
+type VariantTokens = {
+ border: string;
+ surface: string;
+ ink: string;
+};
+
+const VARIANT_TOKENS: Record = {
+ neutral: {
+ border: 'var(--color--border)',
+ surface: 'var(--color--light-bg, var(--color--surface))',
+ ink: 'var(--color--ink)',
+ },
+ success: {
+ border: 'var(--color--success-soft--border)',
+ surface: 'var(--color--success-soft--surface)',
+ ink: 'var(--color--success-soft--ink)',
+ },
+ error: {
+ border: 'var(--color--danger-soft--border)',
+ surface: 'var(--color--danger-soft--surface)',
+ ink: 'var(--color--danger-soft--ink)',
+ },
+ warning: {
+ border: 'var(--color--warning-soft--border)',
+ surface: 'var(--color--warning-soft--surface)',
+ ink: 'var(--color--warning-soft--ink)',
+ },
+};
+
+/**
+ * Presentational status panel used across the setup steps and the overview.
+ * The `variant` selects a soft-color palette matching the DatoCMS dashboard;
+ * content is arbitrary so callers can render remediation text or lists.
+ */
+export const StatusBox = ({ variant, title, children, style }: StatusBoxProps) => {
+ const tokens = VARIANT_TOKENS[variant];
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {children}
+
+ );
+};
diff --git a/automatic-environment-backups/src/config/StatusOverview.tsx b/automatic-environment-backups/src/config/StatusOverview.tsx
new file mode 100644
index 00000000..e1158e28
--- /dev/null
+++ b/automatic-environment-backups/src/config/StatusOverview.tsx
@@ -0,0 +1,225 @@
+import { Button } from 'datocms-react-ui';
+import type { CSSProperties } from 'react';
+import type { BackupOverviewRow } from '../types/types';
+import { getCadenceLabel, normalizeBackupScheduleConfig } from '../utils/backupSchedule';
+import { buildBackupOverviewRows } from '../utils/buildBackupOverviewRows';
+import {
+ buildStatusChecklist,
+ type ChecklistItem,
+ type ChecklistStatus,
+} from './deriveStepStatuses';
+import { StatusBox } from './StatusBox';
+import type { BackupsConfig } from './useBackupsConfig';
+
+const CHECKLIST_MARK: Record =
+ {
+ ok: { symbol: '✓', color: 'var(--color--success-soft--ink)' },
+ error: { symbol: '✕', color: 'var(--color--danger-soft--ink)' },
+ warn: { symbol: '!', color: 'var(--color--warning-soft--ink)' },
+ pending: { symbol: '•', color: 'var(--color--ink-subtle)' },
+ };
+
+const rowStyle: CSSProperties = {
+ border: '1px solid var(--color--border)',
+ borderRadius: '6px',
+ padding: 'var(--spacing-m)',
+ background: 'var(--color--surface)',
+ display: 'grid',
+ gridTemplateColumns: 'minmax(0, 1fr) auto',
+ columnGap: 'var(--spacing-m)',
+ alignItems: 'center',
+};
+
+const rowInfoStyle: CSSProperties = {
+ margin: 0,
+ fontSize: 'var(--font-size-s)',
+};
+
+const ChecklistRow = ({ item }: { item: ChecklistItem }) => {
+ const mark = CHECKLIST_MARK[item.status];
+ return (
+
+
+ {mark.symbol}
+
+
+ {item.label}
+ {item.detail && (
+
+ {' '}
+ — {item.detail}
+
+ )}
+
+
+ );
+};
+
+/**
+ * Section 4 — always-visible, read-only overview. Renders the live checklist,
+ * per-cadence backup rows with [Backup now], and a summary banner. Deliberately
+ * redundant with the per-step errors so a broken setup is impossible to miss.
+ */
+export const StatusOverview = ({
+ config,
+ isConfiguredAndReady,
+}: {
+ config: BackupsConfig;
+ isConfiguredAndReady: boolean;
+}) => {
+ const {
+ params,
+ projectTimezone,
+ lambdaBackupStatus,
+ availableEnvironmentIds,
+ overviewError,
+ isLoadingOverview,
+ backupNowInFlightCadence,
+ canBackupNow,
+ backupNow,
+ onOpenEnvironments,
+ } = config;
+
+ const checklist = buildStatusChecklist(params, lambdaBackupStatus);
+ const scheduleConfig = normalizeBackupScheduleConfig({
+ value: params?.backupSchedule,
+ timezoneFallback: projectTimezone,
+ }).config;
+ const overviewRows: BackupOverviewRow[] = buildBackupOverviewRows({
+ scheduleConfig,
+ lambdaStatus: lambdaBackupStatus,
+ availableEnvironmentIds,
+ });
+
+ return (
+
+
+ Status overview
+
+
+
+ {isConfiguredAndReady
+ ? 'Configured and ready — backups run daily at 02:05 UTC. You can leave this screen.'
+ : 'Needs attention — see the highlighted step above.'}
+
+
+
+ {checklist.map((item) => (
+
+ ))}
+
+
+ {isLoadingOverview && (
+
+ Refreshing backup status…
+
+ )}
+
+ {overviewError && (
+
+ {overviewError}
+
+ )}
+
+
+ {overviewRows.map((row) => {
+ const isRowLoading = backupNowInFlightCadence === row.scope;
+ const isRowDisabled =
+ !canBackupNow || backupNowInFlightCadence !== null;
+
+ return (
+
+
+
+ {
+ void backupNow(row.scope);
+ }}
+ disabled={isRowDisabled}
+ >
+ {isRowLoading ? 'Backing up…' : 'Backup now'}
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/automatic-environment-backups/src/config/StepConnect.tsx b/automatic-environment-backups/src/config/StepConnect.tsx
new file mode 100644
index 00000000..1acdf5d5
--- /dev/null
+++ b/automatic-environment-backups/src/config/StepConnect.tsx
@@ -0,0 +1,115 @@
+import { Button, TextField } from 'datocms-react-ui';
+import { useState } from 'react';
+import { StatusBox } from './StatusBox';
+import type { BackupsConfig } from './useBackupsConfig';
+
+/**
+ * Step 2 — point the plugin at the deployed function and verify it responds and
+ * authenticates. The single action persists the URL triplet, runs the health
+ * ping with the saved secret, and persists the resulting connection state; the
+ * `StatusBox` reflects testing / connected / failed with the exact reason.
+ */
+export const StepConnect = ({ config }: { config: BackupsConfig }) => {
+ const {
+ urlInput,
+ setUrlInput,
+ saveAndTestConnection,
+ disconnect,
+ isConnecting,
+ isMountChecking,
+ isDisconnecting,
+ isConnected,
+ connection,
+ connectionErrorDetails,
+ connectionTestError,
+ savedUrl,
+ } = config;
+
+ const [showDetails, setShowDetails] = useState(false);
+
+ const isTesting = isConnecting || isMountChecking;
+ const hasSavedUrl = savedUrl.trim().length > 0;
+ const persistedError =
+ !isConnected && connection?.status === 'disconnected'
+ ? connection.errorMessage ??
+ 'Last connection check failed. Re-test the connection.'
+ : null;
+
+ return (
+ <>
+
+
+
+ {
+ void saveAndTestConnection();
+ }}
+ disabled={isTesting || isDisconnecting}
+ >
+ {isConnecting ? 'Testing…' : 'Save & test connection'}
+
+ {hasSavedUrl && (
+ {
+ void disconnect();
+ }}
+ disabled={isTesting || isDisconnecting}
+ >
+ {isDisconnecting ? 'Disconnecting…' : 'Disconnect'}
+
+ )}
+
+
+ {isTesting ? (
+ Testing connection…
+ ) : connectionTestError ? (
+
+ {connectionTestError.details.length > 0 && (
+
+ {connectionTestError.details.map((detail) => (
+ {detail}
+ ))}
+
+ )}
+
+ ) : isConnected ? (
+
+ The function responds and authenticates.
+
+ ) : persistedError && hasSavedUrl ? (
+
+ {persistedError}
+ {connectionErrorDetails.length > 0 && (
+
+
setShowDetails((current) => !current)}
+ >
+ {showDetails ? 'Hide details' : 'Show details'}
+
+ {showDetails && (
+
+ {connectionErrorDetails.map((detail) => (
+ {detail}
+ ))}
+
+ )}
+
+ )}
+
+ ) : null}
+ >
+ );
+};
diff --git a/automatic-environment-backups/src/config/StepSchedule.tsx b/automatic-environment-backups/src/config/StepSchedule.tsx
new file mode 100644
index 00000000..fd028b39
--- /dev/null
+++ b/automatic-environment-backups/src/config/StepSchedule.tsx
@@ -0,0 +1,61 @@
+import { Button, SwitchField } from 'datocms-react-ui';
+import type { CSSProperties } from 'react';
+import { BACKUP_CADENCES, getCadenceLabel } from '../utils/backupSchedule';
+import { StatusBox } from './StatusBox';
+import type { BackupsConfig } from './useBackupsConfig';
+
+const switchNoHintGapStyle = {
+ '--spacing-s': '0',
+} as CSSProperties;
+
+/**
+ * Step 3 — choose how often backups run. Saving persists the normalized backup
+ * schedule and then ensures a backup environment exists for each enabled
+ * cadence, reporting progress while the initial sandboxes are created.
+ */
+export const StepSchedule = ({ config }: { config: BackupsConfig }) => {
+ const {
+ cadenceSelection,
+ setCadenceEnabled,
+ saveSchedule,
+ isSavingSchedule,
+ progressMessage,
+ } = config;
+
+ const hasSelection = cadenceSelection.length > 0;
+
+ return (
+ <>
+
+ {BACKUP_CADENCES.map((cadence) => (
+
+ setCadenceEnabled(cadence, newValue)}
+ />
+
+ ))}
+
+
+
+ {
+ void saveSchedule();
+ }}
+ disabled={isSavingSchedule || !hasSelection}
+ >
+ {isSavingSchedule ? 'Saving…' : 'Save & continue'}
+
+
+
+ {progressMessage && (
+ {progressMessage}
+ )}
+ >
+ );
+};
diff --git a/automatic-environment-backups/src/config/StepSecret.tsx b/automatic-environment-backups/src/config/StepSecret.tsx
new file mode 100644
index 00000000..baf0b15c
--- /dev/null
+++ b/automatic-environment-backups/src/config/StepSecret.tsx
@@ -0,0 +1,229 @@
+import { Button, TextField } from 'datocms-react-ui';
+import {
+ type CSSProperties,
+ type KeyboardEvent,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {
+ DEPLOY_PROVIDER_OPTIONS,
+ type DeployProvider,
+} from '../utils/deployProviders';
+import { StatusBox } from './StatusBox';
+import type { BackupsConfig } from './useBackupsConfig';
+
+const DEPLOY_MENU_ID = 'deploy-provider-menu';
+
+const menuContainerStyle: CSSProperties = {
+ position: 'relative',
+ flex: '1 1 0',
+};
+
+const menuStyle: CSSProperties = {
+ position: 'absolute',
+ top: 'calc(100% + var(--spacing-xs))',
+ left: 0,
+ zIndex: 1000,
+ minWidth: '180px',
+ width: '100%',
+ border: '1px solid var(--color--border)',
+ borderRadius: '6px',
+ background: 'var(--color--surface)',
+ boxShadow: '0 8px 24px rgb(0 0 0 / 12%)',
+ padding: 'var(--spacing-xs) 0',
+};
+
+const menuOptionStyle: CSSProperties = {
+ display: 'block',
+ width: '100%',
+ border: 0,
+ background: 'transparent',
+ color: 'var(--color--ink)',
+ cursor: 'pointer',
+ font: 'inherit',
+ fontSize: 'var(--font-size-s)',
+ lineHeight: 1.3,
+ padding: 'var(--spacing-s) var(--spacing-m)',
+ textAlign: 'left',
+};
+
+/**
+ * Step 1 — create/rotate the shared auth secret and deploy the scheduler. The
+ * secret is generated (fresh install) or loaded as-is, saved explicitly, then a
+ * provider deploy menu + paste callout are revealed. Editing a saved secret
+ * while connected raises a redeploy warning.
+ */
+export const StepSecret = ({ config }: { config: BackupsConfig }) => {
+ const {
+ secretInput,
+ setSecretInput,
+ saveSecret,
+ regenerateSecret,
+ copySecret,
+ isSavingSecret,
+ savedSecret,
+ isConnected,
+ } = config;
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ const hasSavedSecret = savedSecret.trim().length > 0;
+ const trimmedInput = secretInput.trim();
+ const secretEdited = hasSavedSecret && trimmedInput !== savedSecret.trim();
+ const showRedeployWarning = isConnected && secretEdited;
+
+ useEffect(() => {
+ if (!isMenuOpen) {
+ return undefined;
+ }
+
+ const handleOutside = (event: MouseEvent | TouchEvent) => {
+ const target = event.target;
+ if (target instanceof Node && !menuRef.current?.contains(target)) {
+ setIsMenuOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleOutside);
+ document.addEventListener('touchstart', handleOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleOutside);
+ document.removeEventListener('touchstart', handleOutside);
+ };
+ }, [isMenuOpen]);
+
+ const handleDeployClick = (provider: DeployProvider) => {
+ const option = DEPLOY_PROVIDER_OPTIONS.find(
+ (candidate) => candidate.provider === provider,
+ );
+ setIsMenuOpen(false);
+ if (option) {
+ window.open(option.url, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ const handleMenuKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setIsMenuOpen(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ Generate
+
+ {
+ void copySecret();
+ }}
+ disabled={trimmedInput.length === 0}
+ >
+ Copy
+
+ {
+ void saveSecret();
+ }}
+ disabled={isSavingSecret || trimmedInput.length === 0}
+ >
+ {isSavingSecret ? 'Saving…' : 'Save secret'}
+
+
+
+ {showRedeployWarning && (
+
+ Changing this means updating DATOCMS_BACKUPS_SHARED_SECRET{' '}
+ on your deployment and redeploying, or the connection will fail.
+
+ )}
+
+ {hasSavedSecret && (
+ <>
+
+ Paste this value as DATOCMS_BACKUPS_SHARED_SECRET on your
+ provider, then come back with the deployed URL.
+
+ {savedSecret}
+
+
+
+
+ setIsMenuOpen((current) => !current)}
+ aria-haspopup="menu"
+ aria-expanded={isMenuOpen}
+ aria-controls={isMenuOpen ? DEPLOY_MENU_ID : undefined}
+ style={{ width: '100%' }}
+ >
+ Deploy to ▾
+
+
+ {isMenuOpen && (
+
+ )}
+
+ >
+ )}
+ >
+ );
+};
diff --git a/automatic-environment-backups/src/config/StepSection.tsx b/automatic-environment-backups/src/config/StepSection.tsx
new file mode 100644
index 00000000..7a3ce7ed
--- /dev/null
+++ b/automatic-environment-backups/src/config/StepSection.tsx
@@ -0,0 +1,210 @@
+import { Button } from 'datocms-react-ui';
+import type { CSSProperties, ReactNode } from 'react';
+import type { StepStatus } from './deriveStepStatuses';
+
+type StepSectionProps = {
+ stepNumber: number;
+ title: string;
+ /** One-line "what & why" shown under the title. */
+ description: string;
+ status: StepStatus;
+ isExpanded: boolean;
+ onToggle: () => void;
+ /** Collapsed one-line summary shown when the step is `ok`. */
+ summary?: ReactNode;
+ children: ReactNode;
+};
+
+type BadgeTokens = { label: string; ink: string; surface: string; border: string };
+
+const STATUS_BADGE: Partial> = {
+ ok: {
+ label: 'OK',
+ ink: 'var(--color--success-soft--ink)',
+ surface: 'var(--color--success-soft--surface)',
+ border: 'var(--color--success-soft--border)',
+ },
+ current: {
+ label: 'Current step',
+ ink: 'var(--color--primary, #1a56db)',
+ surface: 'var(--color--light-bg, var(--color--surface))',
+ border: 'var(--color--primary, #1a56db)',
+ },
+ error: {
+ label: 'Error',
+ ink: 'var(--color--danger-soft--ink)',
+ surface: 'var(--color--danger-soft--surface)',
+ border: 'var(--color--danger-soft--border)',
+ },
+};
+
+const getNumberCircleColor = (status: StepStatus): string => {
+ if (status === 'ok') {
+ return 'var(--color--success-soft--ink)';
+ }
+ if (status === 'error') {
+ return 'var(--color--danger-soft--ink)';
+ }
+ if (status === 'current') {
+ return 'var(--color--primary, #1a56db)';
+ }
+ return 'var(--color--ink-subtle)';
+};
+
+/**
+ * Accordion section chrome for a single setup step: numbered header, one-line
+ * "what & why", a status badge, and an expand/collapse body. Driven entirely by
+ * `status` and `isExpanded` from the orchestrator — a `disabled` step renders
+ * grayed and non-interactive (no "locked" chrome), an `ok` step collapses to its
+ * summary with an `[Edit]` affordance.
+ */
+export const StepSection = ({
+ stepNumber,
+ title,
+ description,
+ status,
+ isExpanded,
+ onToggle,
+ summary,
+ children,
+}: StepSectionProps) => {
+ const isDisabled = status === 'disabled';
+ const badge = STATUS_BADGE[status];
+ const numberColor = getNumberCircleColor(status);
+
+ const cardStyle: CSSProperties = {
+ border:
+ status === 'current' || status === 'error'
+ ? `1px solid ${numberColor}`
+ : '1px solid var(--color--border)',
+ borderRadius: '6px',
+ background: 'var(--color--surface)',
+ marginBottom: 'var(--spacing-l)',
+ textAlign: 'left',
+ opacity: isDisabled ? 0.55 : 1,
+ };
+
+ const headerStyle: CSSProperties = {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 'var(--spacing-m)',
+ width: '100%',
+ padding: 'var(--spacing-l)',
+ border: 0,
+ background: 'transparent',
+ textAlign: 'left',
+ cursor: isDisabled ? 'default' : 'pointer',
+ font: 'inherit',
+ color: 'var(--color--ink)',
+ };
+
+ const numberBadgeStyle: CSSProperties = {
+ flex: '0 0 auto',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '28px',
+ height: '28px',
+ borderRadius: '999px',
+ border: `2px solid ${numberColor}`,
+ color: numberColor,
+ fontSize: 'var(--font-size-s)',
+ fontWeight: 600,
+ lineHeight: 1,
+ };
+
+ return (
+
+
+
+ {stepNumber}
+
+
+
+ {title}
+ {badge && (
+
+ {badge.label}
+
+ )}
+
+
+ {description}
+
+
+
+
+ {!isDisabled && isExpanded && (
+
+ {children}
+
+ )}
+
+ {!isDisabled && !isExpanded && status === 'ok' && (
+
+
+ {summary}
+
+
+ Edit
+
+
+ )}
+
+ );
+};
diff --git a/automatic-environment-backups/src/config/deriveStepStatuses.test.ts b/automatic-environment-backups/src/config/deriveStepStatuses.test.ts
new file mode 100644
index 00000000..27b371db
--- /dev/null
+++ b/automatic-environment-backups/src/config/deriveStepStatuses.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it } from 'vitest';
+import type {
+ BackupScheduleConfig,
+ LambdaBackupStatus,
+ LambdaConnectionState,
+} from '../types/types';
+import { buildStatusChecklist, deriveStepStatuses } from './deriveStepStatuses';
+
+const connected: LambdaConnectionState = {
+ status: 'connected',
+ endpoint: 'https://x/api/datocms/plugin-health',
+ lastCheckedAt: '2026-06-30T00:00:00.000Z',
+ lastCheckPhase: 'config_connect',
+};
+
+const failedPing: LambdaConnectionState = {
+ status: 'disconnected',
+ endpoint: 'https://x/api/datocms/plugin-health',
+ lastCheckedAt: '2026-06-30T00:00:00.000Z',
+ lastCheckPhase: 'config_mount',
+ errorCode: 'HTTP',
+ errorMessage: 'HTTP 401: UNAUTHORIZED - Missing or invalid header.',
+ httpStatus: 401,
+};
+
+const storedSchedule: BackupScheduleConfig = {
+ version: 1,
+ enabledCadences: ['daily', 'weekly'],
+ timezone: 'UTC',
+ anchorLocalDate: '2026-06-30',
+ updatedAt: '2026-06-30T00:00:00.000Z',
+};
+
+const freshInstall = {};
+const secretOnly = { lambdaAuthSecret: 'a-real-secret' };
+const connectionFailed = {
+ lambdaAuthSecret: 'a-real-secret',
+ deploymentURL: 'https://x',
+ lambdaConnection: failedPing,
+ connectionValidationMode: null,
+};
+const connectedNoSchedule = {
+ lambdaAuthSecret: 'a-real-secret',
+ deploymentURL: 'https://x',
+ lambdaConnection: connected,
+ connectionValidationMode: 'health',
+};
+const fullyConfigured = {
+ ...connectedNoSchedule,
+ backupSchedule: storedSchedule,
+};
+const brokeSinceLastVisit = {
+ ...fullyConfigured,
+ lambdaConnection: failedPing,
+ connectionValidationMode: null,
+};
+
+describe('deriveStepStatuses', () => {
+ it('fresh install: secret is current, later steps disabled', () => {
+ expect(deriveStepStatuses(freshInstall)).toEqual({
+ secret: 'current',
+ connect: 'disabled',
+ schedule: 'disabled',
+ currentStep: 'secret',
+ });
+ });
+
+ it('secret saved: connect becomes current, schedule still disabled', () => {
+ expect(deriveStepStatuses(secretOnly)).toEqual({
+ secret: 'ok',
+ connect: 'current',
+ schedule: 'disabled',
+ currentStep: 'connect',
+ });
+ });
+
+ it('failed ping: connect is error and is the current focus', () => {
+ expect(deriveStepStatuses(connectionFailed)).toEqual({
+ secret: 'ok',
+ connect: 'error',
+ schedule: 'disabled',
+ currentStep: 'connect',
+ });
+ });
+
+ it('connected without a saved schedule: schedule is current', () => {
+ expect(deriveStepStatuses(connectedNoSchedule)).toEqual({
+ secret: 'ok',
+ connect: 'ok',
+ schedule: 'current',
+ currentStep: 'schedule',
+ });
+ });
+
+ it('fully configured: everything ok, no current step', () => {
+ expect(deriveStepStatuses(fullyConfigured)).toEqual({
+ secret: 'ok',
+ connect: 'ok',
+ schedule: 'ok',
+ currentStep: null,
+ });
+ });
+
+ it('broke since last visit: connect flips to error and takes focus; schedule re-gates', () => {
+ const statuses = deriveStepStatuses(brokeSinceLastVisit);
+ expect(statuses.connect).toBe('error');
+ expect(statuses.schedule).toBe('disabled');
+ expect(statuses.currentStep).toBe('connect');
+ });
+});
+
+describe('buildStatusChecklist', () => {
+ it('fresh install: everything pending', () => {
+ const items = buildStatusChecklist(freshInstall);
+ expect(items.map((item) => [item.id, item.status])).toEqual([
+ ['secret', 'pending'],
+ ['connection', 'pending'],
+ ['cadence', 'pending'],
+ ['environments', 'pending'],
+ ]);
+ });
+
+ it('warns when the secret is still the example default', () => {
+ const items = buildStatusChecklist({
+ lambdaAuthSecret: 'superSecretToken',
+ });
+ const secret = items.find((item) => item.id === 'secret');
+ expect(secret?.status).toBe('warn');
+ });
+
+ it('surfaces the connection error message redundantly', () => {
+ const connection = buildStatusChecklist(connectionFailed).find(
+ (item) => item.id === 'connection',
+ );
+ expect(connection?.status).toBe('error');
+ expect(connection?.detail).toContain('401');
+ });
+
+ it('reports created environments once a backup status is available', () => {
+ const backupStatus = {
+ scheduler: { provider: 'netlify', cadence: 'daily' },
+ slots: {
+ daily: {
+ scope: 'daily',
+ executionMode: 'lambda_cron',
+ lastBackupAt: '2026-06-29T02:05:00.000Z',
+ nextBackupAt: '2026-06-30T02:05:00.000Z',
+ },
+ weekly: {
+ scope: 'weekly',
+ executionMode: 'lambda_cron',
+ lastBackupAt: null,
+ nextBackupAt: '2026-07-05T02:05:00.000Z',
+ },
+ },
+ checkedAt: '2026-06-30T00:00:00.000Z',
+ } as LambdaBackupStatus;
+
+ const environments = buildStatusChecklist(
+ fullyConfigured,
+ backupStatus,
+ ).find((item) => item.id === 'environments');
+ expect(environments?.status).toBe('pending');
+ expect(environments?.detail).toContain('1 of 2');
+ });
+});
diff --git a/automatic-environment-backups/src/config/deriveStepStatuses.ts b/automatic-environment-backups/src/config/deriveStepStatuses.ts
new file mode 100644
index 00000000..bb98b8d2
--- /dev/null
+++ b/automatic-environment-backups/src/config/deriveStepStatuses.ts
@@ -0,0 +1,194 @@
+import type { BackupCadence, LambdaBackupStatus } from '../types/types';
+import { getCadenceLabel } from '../utils/backupSchedule';
+import {
+ type BackupsParameters,
+ hasStoredBackupSchedule,
+ isConnectionHealthy,
+ isDefaultAuthSecret,
+ readAuthSecret,
+ readConnection,
+ readDeploymentUrl,
+ readEnabledCadences,
+} from './pluginParams';
+
+/** Per-step status in the gated accordion. `disabled` renders grayed (unreached). */
+export type StepStatus = 'ok' | 'current' | 'error' | 'disabled';
+
+export type SetupStepId = 'secret' | 'connect' | 'schedule';
+
+export type StepStatuses = {
+ secret: StepStatus;
+ connect: StepStatus;
+ schedule: StepStatus;
+ /** First non-ok setup step (the one to work on), or null when all are ok. */
+ currentStep: SetupStepId | null;
+};
+
+/**
+ * Derive the accordion step statuses purely from the saved plugin parameters.
+ * Exactly one setup step is `current`/`error` at a time; earlier steps are `ok`,
+ * later steps `disabled`. When all three are `ok`, `currentStep` is null.
+ */
+export const deriveStepStatuses = (params: BackupsParameters): StepStatuses => {
+ const secretSet = readAuthSecret(params) !== '';
+ const connected = isConnectionHealthy(params);
+ const urlSet = readDeploymentUrl(params) !== '';
+ const hasFailedPing =
+ urlSet && !connected && readConnection(params)?.status === 'disconnected';
+ const scheduleSet =
+ hasStoredBackupSchedule(params) && readEnabledCadences(params).length > 0;
+
+ const secret: StepStatus = secretSet ? 'ok' : 'current';
+
+ let connect: StepStatus;
+ if (!secretSet) {
+ connect = 'disabled';
+ } else if (connected) {
+ connect = 'ok';
+ } else if (hasFailedPing) {
+ connect = 'error';
+ } else {
+ connect = 'current';
+ }
+
+ let schedule: StepStatus;
+ if (!connected) {
+ schedule = 'disabled';
+ } else if (scheduleSet) {
+ schedule = 'ok';
+ } else {
+ schedule = 'current';
+ }
+
+ const currentStep: SetupStepId | null =
+ secret !== 'ok'
+ ? 'secret'
+ : connect !== 'ok'
+ ? 'connect'
+ : schedule !== 'ok'
+ ? 'schedule'
+ : null;
+
+ return { secret, connect, schedule, currentStep };
+};
+
+export type ChecklistStatus = 'ok' | 'error' | 'pending' | 'warn';
+
+export type ChecklistItem = {
+ id: 'secret' | 'connection' | 'cadence' | 'environments';
+ label: string;
+ status: ChecklistStatus;
+ detail?: string;
+};
+
+/**
+ * Build the Status-overview checklist. Deliberately redundant with per-step
+ * errors so a broken setup is impossible to miss. Reads params for the first
+ * three items; the environments item also needs the fetched backup status.
+ */
+export const buildStatusChecklist = (
+ params: BackupsParameters,
+ backupStatus?: LambdaBackupStatus,
+): ChecklistItem[] => {
+ const secret = readAuthSecret(params);
+ const connection = readConnection(params);
+ const connected = isConnectionHealthy(params);
+ const urlSet = readDeploymentUrl(params) !== '';
+ const enabledCadences = readEnabledCadences(params);
+ const scheduleSet =
+ hasStoredBackupSchedule(params) && enabledCadences.length > 0;
+
+ const secretItem: ChecklistItem =
+ secret === ''
+ ? { id: 'secret', label: 'Auth secret', status: 'pending', detail: 'Not set yet.' }
+ : isDefaultAuthSecret(secret)
+ ? {
+ id: 'secret',
+ label: 'Auth secret',
+ status: 'warn',
+ detail: 'Using the example default — regenerate a unique secret.',
+ }
+ : { id: 'secret', label: 'Auth secret', status: 'ok', detail: 'Set.' };
+
+ const connectionItem: ChecklistItem = !urlSet
+ ? {
+ id: 'connection',
+ label: 'Function reachable & authenticating',
+ status: 'pending',
+ detail: 'No deployment URL yet.',
+ }
+ : connected
+ ? {
+ id: 'connection',
+ label: 'Function reachable & authenticating',
+ status: 'ok',
+ detail: 'Connected.',
+ }
+ : {
+ id: 'connection',
+ label: 'Function reachable & authenticating',
+ status: 'error',
+ detail:
+ connection?.errorMessage ??
+ 'Last connection check failed. Re-test in the Connect step.',
+ };
+
+ const cadenceItem: ChecklistItem = scheduleSet
+ ? {
+ id: 'cadence',
+ label: 'Backup cadence',
+ status: 'ok',
+ detail: enabledCadences.map(getCadenceLabel).join(', '),
+ }
+ : {
+ id: 'cadence',
+ label: 'Backup cadence',
+ status: 'pending',
+ detail: 'Not configured yet.',
+ };
+
+ const environmentsItem = buildEnvironmentsItem(
+ connected,
+ enabledCadences,
+ backupStatus,
+ );
+
+ return [secretItem, connectionItem, cadenceItem, environmentsItem];
+};
+
+const buildEnvironmentsItem = (
+ connected: boolean,
+ enabledCadences: BackupCadence[],
+ backupStatus?: LambdaBackupStatus,
+): ChecklistItem => {
+ const label = 'Backup environments';
+ if (!connected) {
+ return {
+ id: 'environments',
+ label,
+ status: 'pending',
+ detail: 'Waiting for a healthy connection.',
+ };
+ }
+
+ if (!backupStatus) {
+ return {
+ id: 'environments',
+ label,
+ status: 'pending',
+ detail: 'Loading backup status…',
+ };
+ }
+
+ const total = enabledCadences.length;
+ const created = enabledCadences.filter(
+ (cadence) => backupStatus.slots[cadence]?.lastBackupAt,
+ ).length;
+
+ return {
+ id: 'environments',
+ label,
+ status: total > 0 && created === total ? 'ok' : 'pending',
+ detail: `${created} of ${total} created`,
+ };
+};
diff --git a/automatic-environment-backups/src/config/generateAuthSecret.test.ts b/automatic-environment-backups/src/config/generateAuthSecret.test.ts
new file mode 100644
index 00000000..c83f3927
--- /dev/null
+++ b/automatic-environment-backups/src/config/generateAuthSecret.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { AUTH_SECRET_HEX_LENGTH, generateAuthSecret } from './generateAuthSecret';
+
+describe('generateAuthSecret', () => {
+ it('returns a string of the documented length (128-bit → 32 hex chars)', () => {
+ expect(AUTH_SECRET_HEX_LENGTH).toBe(32);
+ expect(generateAuthSecret()).toHaveLength(32);
+ });
+
+ it('contains only lowercase hex characters, so it is URL-safe and portable across providers', () => {
+ for (let index = 0; index < 20; index += 1) {
+ expect(generateAuthSecret()).toMatch(/^[0-9a-f]{32}$/);
+ }
+ });
+
+ it('produces a distinct value on each call', () => {
+ const values = new Set(
+ Array.from({ length: 100 }, () => generateAuthSecret()),
+ );
+ expect(values.size).toBe(100);
+ });
+});
diff --git a/automatic-environment-backups/src/config/generateAuthSecret.ts b/automatic-environment-backups/src/config/generateAuthSecret.ts
new file mode 100644
index 00000000..5c9a2f1b
--- /dev/null
+++ b/automatic-environment-backups/src/config/generateAuthSecret.ts
@@ -0,0 +1,21 @@
+/** Bytes of entropy (128 bits) behind a generated lambda auth secret. */
+export const AUTH_SECRET_BYTE_LENGTH = 16;
+
+/** Length of the hex-encoded secret produced by {@link generateAuthSecret}. */
+export const AUTH_SECRET_HEX_LENGTH = AUTH_SECRET_BYTE_LENGTH * 2;
+
+/**
+ * Generate a random lambda auth secret: 128 bits of entropy as a 32-character
+ * lowercase hex string. Hex keeps it URL-safe and comfortably within every
+ * deploy provider's environment-variable value limit.
+ *
+ * Uses the Web Crypto API (`crypto.getRandomValues`), which is available in both
+ * the browser plugin runtime and Node's test environment.
+ */
+export const generateAuthSecret = (): string => {
+ const bytes = new Uint8Array(AUTH_SECRET_BYTE_LENGTH);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
+ '',
+ );
+};
diff --git a/automatic-environment-backups/src/config/pluginParams.test.ts b/automatic-environment-backups/src/config/pluginParams.test.ts
new file mode 100644
index 00000000..26b09ae1
--- /dev/null
+++ b/automatic-environment-backups/src/config/pluginParams.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from 'vitest';
+import type { LambdaConnectionState } from '../types/types';
+import {
+ DEFAULT_LAMBDA_AUTH_SECRET,
+ getProjectTimezone,
+ hasStoredBackupSchedule,
+ isConnectionHealthy,
+ isDefaultAuthSecret,
+ readAuthSecret,
+ readConnection,
+ readDebug,
+ readDeploymentUrl,
+ readEnabledCadences,
+ readValidationMode,
+} from './pluginParams';
+
+const connected: LambdaConnectionState = {
+ status: 'connected',
+ endpoint: 'https://x/api/datocms/plugin-health',
+ lastCheckedAt: '2026-06-30T00:00:00.000Z',
+ lastCheckPhase: 'config_connect',
+};
+
+const disconnected: LambdaConnectionState = {
+ status: 'disconnected',
+ endpoint: 'https://x/api/datocms/plugin-health',
+ lastCheckedAt: '2026-06-30T00:00:00.000Z',
+ lastCheckPhase: 'config_mount',
+ errorCode: 'HTTP',
+ errorMessage: 'HTTP 401: UNAUTHORIZED',
+ httpStatus: 401,
+};
+
+describe('readAuthSecret', () => {
+ it('returns the trimmed saved secret', () => {
+ expect(readAuthSecret({ lambdaAuthSecret: ' abc123 ' })).toBe('abc123');
+ });
+
+ it('returns empty string when unset, blank, or non-string (no superSecretToken default)', () => {
+ expect(readAuthSecret(undefined)).toBe('');
+ expect(readAuthSecret({})).toBe('');
+ expect(readAuthSecret({ lambdaAuthSecret: ' ' })).toBe('');
+ expect(readAuthSecret({ lambdaAuthSecret: 123 })).toBe('');
+ });
+});
+
+describe('isDefaultAuthSecret', () => {
+ it('detects the example default so the UI can nudge a rotation', () => {
+ expect(isDefaultAuthSecret(DEFAULT_LAMBDA_AUTH_SECRET)).toBe(true);
+ expect(isDefaultAuthSecret('superSecretToken')).toBe(true);
+ expect(isDefaultAuthSecret('a-real-secret')).toBe(false);
+ expect(isDefaultAuthSecret('')).toBe(false);
+ });
+});
+
+describe('readDeploymentUrl', () => {
+ it('prefers deploymentURL, then netlifyURL, then vercelURL', () => {
+ expect(
+ readDeploymentUrl({
+ deploymentURL: 'https://d',
+ netlifyURL: 'https://n',
+ vercelURL: 'https://v',
+ }),
+ ).toBe('https://d');
+ expect(readDeploymentUrl({ netlifyURL: 'https://n' })).toBe('https://n');
+ expect(readDeploymentUrl({ vercelURL: 'https://v' })).toBe('https://v');
+ });
+
+ it('trims and returns empty when none configured', () => {
+ expect(readDeploymentUrl({ deploymentURL: ' https://d ' })).toBe(
+ 'https://d',
+ );
+ expect(readDeploymentUrl({})).toBe('');
+ expect(readDeploymentUrl(undefined)).toBe('');
+ });
+});
+
+describe('readConnection / readValidationMode', () => {
+ it('returns a well-formed connection object or undefined', () => {
+ expect(readConnection({ lambdaConnection: connected })).toEqual(connected);
+ expect(readConnection({ lambdaConnection: { status: 'bogus' } })).toBeUndefined();
+ expect(readConnection({})).toBeUndefined();
+ });
+
+ it('only accepts the literal health validation mode', () => {
+ expect(readValidationMode({ connectionValidationMode: 'health' })).toBe(
+ 'health',
+ );
+ expect(readValidationMode({ connectionValidationMode: 'other' })).toBeUndefined();
+ expect(readValidationMode({})).toBeUndefined();
+ });
+});
+
+describe('isConnectionHealthy', () => {
+ it('is true only when connected AND validation mode is health', () => {
+ expect(
+ isConnectionHealthy({
+ lambdaConnection: connected,
+ connectionValidationMode: 'health',
+ }),
+ ).toBe(true);
+ expect(isConnectionHealthy({ lambdaConnection: connected })).toBe(false);
+ expect(
+ isConnectionHealthy({
+ lambdaConnection: disconnected,
+ connectionValidationMode: 'health',
+ }),
+ ).toBe(false);
+ expect(isConnectionHealthy({})).toBe(false);
+ });
+});
+
+describe('readDebug', () => {
+ it('is true only for an explicit boolean true', () => {
+ expect(readDebug({ debug: true })).toBe(true);
+ expect(readDebug({ debug: false })).toBe(false);
+ expect(readDebug({ debug: 'true' })).toBe(false);
+ expect(readDebug({})).toBe(false);
+ });
+});
+
+describe('hasStoredBackupSchedule / readEnabledCadences', () => {
+ it('detects an explicitly stored schedule (not the derived default)', () => {
+ expect(
+ hasStoredBackupSchedule({
+ backupSchedule: {
+ version: 1,
+ enabledCadences: ['daily'],
+ timezone: 'UTC',
+ anchorLocalDate: '2026-06-30',
+ updatedAt: '2026-06-30T00:00:00.000Z',
+ },
+ }),
+ ).toBe(true);
+ expect(hasStoredBackupSchedule({})).toBe(false);
+ expect(hasStoredBackupSchedule({ backupSchedule: null })).toBe(false);
+ });
+
+ it('normalizes enabled cadences, defaulting to daily+weekly when absent', () => {
+ expect(
+ readEnabledCadences({
+ backupSchedule: {
+ version: 1,
+ enabledCadences: ['monthly'],
+ timezone: 'UTC',
+ anchorLocalDate: '2026-06-30',
+ updatedAt: '2026-06-30T00:00:00.000Z',
+ },
+ }),
+ ).toEqual(['monthly']);
+ expect(readEnabledCadences({})).toEqual(['daily', 'weekly']);
+ });
+});
+
+describe('getProjectTimezone', () => {
+ it('reads a trimmed site timezone, defaulting to UTC', () => {
+ expect(getProjectTimezone({ timezone: ' Europe/Rome ' })).toBe(
+ 'Europe/Rome',
+ );
+ expect(getProjectTimezone({})).toBe('UTC');
+ expect(getProjectTimezone(null)).toBe('UTC');
+ });
+});
diff --git a/automatic-environment-backups/src/config/pluginParams.ts b/automatic-environment-backups/src/config/pluginParams.ts
new file mode 100644
index 00000000..14abc4e8
--- /dev/null
+++ b/automatic-environment-backups/src/config/pluginParams.ts
@@ -0,0 +1,99 @@
+import type {
+ BackupCadence,
+ ConnectionValidationMode,
+ LambdaConnectionState,
+} from '../types/types';
+import { normalizeBackupScheduleConfig } from '../utils/backupSchedule';
+
+/**
+ * Typed, pure readers over the persisted plugin parameters. These are the single
+ * source of truth for the config screen: everything is derived from
+ * `ctx.plugin.attributes.parameters`, never from a separate React snapshot that
+ * could drift. Keep them free of `ctx`/state so they stay unit-testable.
+ */
+
+/** The example secret shipped in docs and the lambda's server-side fallback. */
+export const DEFAULT_LAMBDA_AUTH_SECRET = 'superSecretToken';
+
+export type BackupsParameters = Record | undefined;
+
+const readTrimmedString = (value: unknown): string =>
+ typeof value === 'string' ? value.trim() : '';
+
+/** The saved auth secret, or `''` when unset — no `superSecretToken` default. */
+export const readAuthSecret = (params: BackupsParameters): string =>
+ readTrimmedString(params?.lambdaAuthSecret);
+
+/** True when a secret is still the shipped example default (worth rotating). */
+export const isDefaultAuthSecret = (secret: string): boolean =>
+ secret === DEFAULT_LAMBDA_AUTH_SECRET;
+
+/** The saved deployment URL, preferring `deploymentURL` over legacy keys. */
+export const readDeploymentUrl = (params: BackupsParameters): string =>
+ readTrimmedString(params?.deploymentURL) ||
+ readTrimmedString(params?.netlifyURL) ||
+ readTrimmedString(params?.vercelURL);
+
+export const readConnection = (
+ params: BackupsParameters,
+): LambdaConnectionState | undefined => {
+ const value = params?.lambdaConnection;
+ if (!value || typeof value !== 'object') {
+ return undefined;
+ }
+
+ const candidate = value as Partial;
+ if (
+ (candidate.status === 'connected' || candidate.status === 'disconnected') &&
+ typeof candidate.endpoint === 'string' &&
+ typeof candidate.lastCheckedAt === 'string' &&
+ (candidate.lastCheckPhase === 'finish_installation' ||
+ candidate.lastCheckPhase === 'config_mount' ||
+ candidate.lastCheckPhase === 'config_connect')
+ ) {
+ return candidate as LambdaConnectionState;
+ }
+
+ return undefined;
+};
+
+export const readValidationMode = (
+ params: BackupsParameters,
+): ConnectionValidationMode | undefined =>
+ params?.connectionValidationMode === 'health' ? 'health' : undefined;
+
+/** True when the last recorded ping succeeded under the health contract. */
+export const isConnectionHealthy = (params: BackupsParameters): boolean =>
+ readConnection(params)?.status === 'connected' &&
+ readValidationMode(params) === 'health';
+
+export const readDebug = (params: BackupsParameters): boolean =>
+ params?.debug === true;
+
+/** True when the user has explicitly persisted a backup schedule. */
+export const hasStoredBackupSchedule = (params: BackupsParameters): boolean =>
+ typeof params?.backupSchedule === 'object' && params?.backupSchedule !== null;
+
+/** Normalized enabled cadences (defaults to daily+weekly when none stored). */
+export const readEnabledCadences = (
+ params: BackupsParameters,
+ timezoneFallback = 'UTC',
+): BackupCadence[] =>
+ normalizeBackupScheduleConfig({
+ value: params?.backupSchedule,
+ timezoneFallback,
+ }).config.enabledCadences;
+
+export const getProjectTimezone = (site: unknown): string => {
+ if (
+ site &&
+ typeof site === 'object' &&
+ 'timezone' in site &&
+ typeof (site as { timezone?: unknown }).timezone === 'string' &&
+ (site as { timezone: string }).timezone.trim()
+ ) {
+ return (site as { timezone: string }).timezone.trim();
+ }
+
+ return 'UTC';
+};
diff --git a/automatic-environment-backups/src/config/useBackupsConfig.ts b/automatic-environment-backups/src/config/useBackupsConfig.ts
new file mode 100644
index 00000000..9c03f3d0
--- /dev/null
+++ b/automatic-environment-backups/src/config/useBackupsConfig.ts
@@ -0,0 +1,984 @@
+import { buildClient } from '@datocms/cma-client-browser';
+import type { RenderConfigScreenCtx } from 'datocms-plugin-sdk';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type {
+ BackupCadence,
+ BackupScheduleConfig,
+ LambdaBackupStatus,
+} from '../types/types';
+import {
+ BACKUP_CADENCES,
+ BACKUP_SCHEDULE_VERSION,
+ getCadenceLabel,
+ normalizeBackupScheduleConfig,
+ toLocalDateKey,
+} from '../utils/backupSchedule';
+import { createDebugLogger } from '../utils/debugLogger';
+import { fetchLambdaBackupStatus } from '../utils/fetchLambdaBackupStatus';
+import {
+ mergePluginParameterUpdates,
+ toPluginParameterRecord,
+} from '../utils/pluginParameterMerging';
+import {
+ LambdaBackupNowError,
+ triggerLambdaBackupNow,
+} from '../utils/triggerLambdaBackupNow';
+import {
+ buildConnectedLambdaConnectionState,
+ buildDisconnectedLambdaConnectionState,
+ getLambdaConnectionErrorDetails,
+ LambdaHealthCheckError,
+ verifyLambdaHealth,
+} from '../utils/verifyLambdaHealth';
+import { generateAuthSecret } from './generateAuthSecret';
+import {
+ type BackupsParameters,
+ getProjectTimezone,
+ isConnectionHealthy,
+ readAuthSecret,
+ readConnection,
+ readDeploymentUrl,
+ readDebug,
+ readEnabledCadences,
+} from './pluginParams';
+
+const MISSING_AUTH_SECRET_MESSAGE =
+ 'Enter Lambda auth secret before calling lambda endpoints.';
+const BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS = 1200;
+
+const getErrorMessage = (error: unknown): string =>
+ error instanceof Error ? error.message : 'Unknown error';
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+const getPluginIdFromCtx = (ctx: RenderConfigScreenCtx): string | undefined => {
+ const candidate = (ctx.plugin as { id?: unknown } | undefined)?.id;
+ return typeof candidate === 'string' && candidate.trim()
+ ? candidate.trim()
+ : undefined;
+};
+
+/** Transient (non-persisted) validation error surfaced by the Connect step. */
+export type ConnectionTestError = {
+ summary: string;
+ details: string[];
+};
+
+/**
+ * Central orchestration hook for the config wizard. Holds the ephemeral edit
+ * state (secret/url/cadence/debug inputs), the queued authoritative-merge
+ * persister, every per-step save+act handler, the run-once mount health ping,
+ * and the overview/environment loaders. All persisted values are read via the
+ * `pluginParams` getters over `ctx.plugin.attributes.parameters` — the single
+ * source of truth — so no separate React snapshot can drift.
+ */
+export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
+ const params = ctx.plugin.attributes.parameters as BackupsParameters;
+ const projectTimezone = getProjectTimezone(ctx.site);
+
+ const savedSecret = readAuthSecret(params);
+ const savedUrl = readDeploymentUrl(params);
+ const connection = readConnection(params);
+ const isConnected = isConnectionHealthy(params);
+
+ // Ephemeral edit-state. A fresh install (no saved secret) pre-fills a strong
+ // generated secret into the field, unsaved until [Save secret].
+ const [secretInput, setSecretInput] = useState(
+ () => savedSecret || generateAuthSecret(),
+ );
+ const [urlInput, setUrlInput] = useState(savedUrl);
+ const [cadenceSelection, setCadenceSelection] = useState(() =>
+ readEnabledCadences(params, projectTimezone),
+ );
+ const [debugEnabled, setDebugEnabled] = useState(() => readDebug(params));
+
+ // Activity flags.
+ const [isSavingSecret, setIsSavingSecret] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [isMountChecking, setIsMountChecking] = useState(false);
+ const [isDisconnecting, setIsDisconnecting] = useState(false);
+ const [isSavingSchedule, setIsSavingSchedule] = useState(false);
+ const [backupNowInFlightCadence, setBackupNowInFlightCadence] =
+ useState(null);
+ const [progressMessage, setProgressMessage] = useState(null);
+
+ // Connect-step transient error (pre-flight validation not written to params).
+ const [connectionTestError, setConnectionTestError] =
+ useState(null);
+
+ // Overview / environment data.
+ const [lambdaBackupStatus, setLambdaBackupStatus] = useState<
+ LambdaBackupStatus | undefined
+ >(undefined);
+ const [availableEnvironmentIds, setAvailableEnvironmentIds] = useState<
+ string[] | undefined
+ >(undefined);
+ const [overviewError, setOverviewError] = useState('');
+ const [isLoadingOverview, setIsLoadingOverview] = useState(false);
+
+ const debugLogger = useMemo(
+ () => createDebugLogger(debugEnabled, 'ConfigScreen'),
+ [debugEnabled],
+ );
+ const debugLoggerRef = useRef(debugLogger);
+ debugLoggerRef.current = debugLogger;
+
+ const persistQueueRef = useRef>(Promise.resolve());
+ const hasRunMountCheckRef = useRef(false);
+ const isMountCheckUnmountedRef = useRef(false);
+
+ // Snapshot of the first-render params so the run-once mount effect always
+ // validates against the values present at load, regardless of later persists.
+ const initialMountRef = useRef<{
+ secret: string;
+ url: string;
+ scheduleNormalization: ReturnType;
+ } | null>(null);
+ if (!initialMountRef.current) {
+ initialMountRef.current = {
+ secret: savedSecret,
+ url: savedUrl,
+ scheduleNormalization: normalizeBackupScheduleConfig({
+ value: params?.backupSchedule,
+ timezoneFallback: projectTimezone,
+ }),
+ };
+ }
+
+ /**
+ * Persist a partial parameter update. Serializes concurrent saves through a
+ * promise queue and re-reads the authoritative parameters from the CMA before
+ * merging, so unrelated keys are never clobbered by a stale local copy.
+ */
+ const persistPluginParameters = useCallback(
+ async (updates: Record) => {
+ const persistTask = async () => {
+ let latestParameters = toPluginParameterRecord(
+ ctx.plugin.attributes.parameters,
+ );
+ const pluginId = getPluginIdFromCtx(ctx);
+
+ if (pluginId && ctx.currentUserAccessToken) {
+ try {
+ const client = buildClient({
+ apiToken: ctx.currentUserAccessToken,
+ environment: ctx.environment,
+ baseUrl: ctx.cmaBaseUrl,
+ });
+ const plugin = await client.plugins.find(pluginId);
+ latestParameters = toPluginParameterRecord(plugin.parameters);
+ } catch (error) {
+ debugLogger.warn(
+ 'Falling back to local plugin parameters because authoritative read failed',
+ { pluginId, error: getErrorMessage(error) },
+ );
+ }
+ }
+
+ await ctx.updatePluginParameters(
+ mergePluginParameterUpdates(latestParameters, updates),
+ );
+ };
+
+ const queuedPersist = persistQueueRef.current.then(
+ persistTask,
+ persistTask,
+ );
+ persistQueueRef.current = queuedPersist.then(
+ () => undefined,
+ () => undefined,
+ );
+ return queuedPersist;
+ },
+ [ctx, debugLogger],
+ );
+
+ const refreshLambdaBackupOverview = useCallback(
+ async (baseUrl?: string) => {
+ const candidateUrl = (baseUrl || savedUrl).trim();
+ const secret = savedSecret.trim();
+ const shouldFetch = candidateUrl.length > 0 && secret.length > 0;
+
+ if (!shouldFetch) {
+ setLambdaBackupStatus(undefined);
+ setOverviewError(
+ candidateUrl.length === 0
+ ? 'Backup status is unavailable until a Lambda URL is connected.'
+ : 'Backup status is unavailable until the auth secret is saved.',
+ );
+ setIsLoadingOverview(false);
+ return;
+ }
+
+ setIsLoadingOverview(true);
+ setOverviewError('');
+
+ try {
+ const status = await fetchLambdaBackupStatus({
+ baseUrl: candidateUrl,
+ environment: ctx.environment,
+ lambdaAuthSecret: secret,
+ });
+ setLambdaBackupStatus(status);
+ } catch (error) {
+ setLambdaBackupStatus(undefined);
+ setOverviewError(
+ error instanceof Error
+ ? error.message
+ : 'Could not load backup overview from lambda status endpoint.',
+ );
+ } finally {
+ setIsLoadingOverview(false);
+ }
+ },
+ [ctx.environment, savedSecret, savedUrl],
+ );
+
+ const fetchAvailableEnvironmentIds = useCallback(async () => {
+ if (!ctx.currentUserAccessToken) {
+ return undefined;
+ }
+
+ try {
+ const client = buildClient({
+ apiToken: ctx.currentUserAccessToken,
+ environment: ctx.environment,
+ baseUrl: ctx.cmaBaseUrl,
+ });
+ const environments = await client.environments.list();
+ return environments
+ .map((environment) => environment.id)
+ .filter((id) => typeof id === 'string' && id.trim().length > 0);
+ } catch {
+ return undefined;
+ }
+ }, [ctx.currentUserAccessToken, ctx.environment, ctx.cmaBaseUrl]);
+
+ const refreshAvailableEnvironments = useCallback(async () => {
+ const environmentIds = await fetchAvailableEnvironmentIds();
+ setAvailableEnvironmentIds(environmentIds);
+ }, [fetchAvailableEnvironmentIds]);
+
+ const triggerBackupForSingleCadence = useCallback(
+ async ({
+ baseUrl,
+ lambdaAuthSecret,
+ cadence,
+ }: {
+ baseUrl: string;
+ lambdaAuthSecret: string;
+ cadence: BackupCadence;
+ }): Promise<{
+ success: boolean;
+ environmentId?: string;
+ errorMessage?: string;
+ }> => {
+ setBackupNowInFlightCadence(cadence);
+ try {
+ const result = await triggerLambdaBackupNow({
+ baseUrl,
+ environment: ctx.environment,
+ scope: cadence,
+ lambdaAuthSecret,
+ });
+ return { success: true, environmentId: result.createdEnvironmentId };
+ } catch (error) {
+ // A freshly-persisted schedule can race the lambda's own run; a 409
+ // means "already creating" — retry once after a short delay.
+ const isRaceCondition =
+ error instanceof LambdaBackupNowError &&
+ error.code === 'HTTP' &&
+ error.httpStatus === 409;
+
+ if (isRaceCondition) {
+ try {
+ await delay(BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS);
+ const retryResult = await triggerLambdaBackupNow({
+ baseUrl,
+ environment: ctx.environment,
+ scope: cadence,
+ lambdaAuthSecret,
+ });
+ return {
+ success: true,
+ environmentId: retryResult.createdEnvironmentId,
+ };
+ } catch (retryError) {
+ return {
+ success: false,
+ errorMessage: `${getCadenceLabel(cadence)}: ${getErrorMessage(retryError)}`,
+ };
+ }
+ }
+
+ return {
+ success: false,
+ errorMessage: `${getCadenceLabel(cadence)}: ${getErrorMessage(error)}`,
+ };
+ }
+ },
+ [ctx.environment],
+ );
+
+ const ensureBackupsExistForCadences = useCallback(
+ async ({
+ baseUrl,
+ lambdaAuthSecret,
+ cadences,
+ }: {
+ baseUrl: string;
+ lambdaAuthSecret: string;
+ cadences: BackupCadence[];
+ }) => {
+ if (cadences.length === 0) {
+ return;
+ }
+
+ try {
+ const status = await fetchLambdaBackupStatus({
+ baseUrl,
+ environment: ctx.environment,
+ lambdaAuthSecret,
+ });
+
+ const missing = cadences.filter(
+ (cadence) => !status.slots[cadence]?.lastBackupAt,
+ );
+
+ if (missing.length === 0) {
+ await refreshLambdaBackupOverview(baseUrl);
+ await refreshAvailableEnvironments();
+ return;
+ }
+
+ setProgressMessage('Creating initial backups…');
+
+ const createdEnvironmentIds: string[] = [];
+ const failedCadences: string[] = [];
+
+ for (const cadence of missing) {
+ setProgressMessage(
+ `Creating ${getCadenceLabel(cadence).toLowerCase()} backup…`,
+ );
+ const outcome = await triggerBackupForSingleCadence({
+ baseUrl,
+ lambdaAuthSecret,
+ cadence,
+ });
+ if (outcome.success && outcome.environmentId) {
+ createdEnvironmentIds.push(outcome.environmentId);
+ } else if (outcome.errorMessage) {
+ failedCadences.push(outcome.errorMessage);
+ }
+ }
+
+ if (createdEnvironmentIds.length > 0) {
+ const plural = createdEnvironmentIds.length > 1 ? 's' : '';
+ ctx.notice(
+ `Created ${createdEnvironmentIds.length} backup environment${plural} for the saved schedule.`,
+ );
+ }
+ if (failedCadences.length > 0) {
+ setOverviewError(
+ `Some automatic backup creations failed: ${failedCadences.join(' | ')}`,
+ );
+ }
+
+ await refreshLambdaBackupOverview(baseUrl);
+ await refreshAvailableEnvironments();
+ } catch (error) {
+ setOverviewError(
+ error instanceof Error
+ ? error.message
+ : 'Could not automatically create missing backup environments.',
+ );
+ } finally {
+ setProgressMessage(null);
+ setBackupNowInFlightCadence(null);
+ }
+ },
+ [
+ ctx,
+ refreshAvailableEnvironments,
+ refreshLambdaBackupOverview,
+ triggerBackupForSingleCadence,
+ ],
+ );
+
+ const runMountHealthCheck = useCallback(
+ async ({
+ configuredDeploymentUrl,
+ isCancelled,
+ }: {
+ configuredDeploymentUrl: string;
+ isCancelled: () => boolean;
+ }) => {
+ const secret = (initialMountRef.current?.secret ?? '').trim();
+
+ if (!secret) {
+ const disconnectedState = buildDisconnectedLambdaConnectionState(
+ new LambdaHealthCheckError({
+ code: 'MISSING_AUTH_SECRET',
+ message: MISSING_AUTH_SECRET_MESSAGE,
+ phase: 'config_mount',
+ endpoint: `${configuredDeploymentUrl.replace(/\/+$/, '')}/api/datocms/plugin-health`,
+ }),
+ configuredDeploymentUrl,
+ 'config_mount',
+ );
+ try {
+ await persistPluginParameters({
+ lambdaConnection: disconnectedState,
+ connectionValidationMode: null,
+ });
+ } catch {
+ // Ignore persistence failure on mount.
+ }
+ if (!isCancelled()) {
+ setIsMountChecking(false);
+ }
+ return;
+ }
+
+ debugLogger.log('Running mount health check', {
+ configuredDeploymentUrl,
+ phase: 'config_mount',
+ });
+
+ try {
+ const verificationResult = await verifyLambdaHealth({
+ baseUrl: configuredDeploymentUrl,
+ environment: ctx.environment,
+ phase: 'config_mount',
+ lambdaAuthSecret: secret,
+ });
+
+ if (isCancelled()) {
+ return;
+ }
+
+ const connectedState = buildConnectedLambdaConnectionState(
+ verificationResult.endpoint,
+ verificationResult.checkedAt,
+ 'config_mount',
+ );
+
+ setUrlInput(verificationResult.normalizedBaseUrl);
+ setConnectionTestError(null);
+
+ await persistPluginParameters({
+ deploymentURL: verificationResult.normalizedBaseUrl,
+ netlifyURL: verificationResult.normalizedBaseUrl,
+ vercelURL: verificationResult.normalizedBaseUrl,
+ lambdaConnection: connectedState,
+ connectionValidationMode: 'health',
+ lambdaAuthSecret: secret,
+ });
+ } catch (error) {
+ if (isCancelled()) {
+ return;
+ }
+
+ const disconnectedState = buildDisconnectedLambdaConnectionState(
+ error,
+ configuredDeploymentUrl,
+ 'config_mount',
+ );
+ debugLogger.warn('Mount health check failed', disconnectedState);
+
+ try {
+ await persistPluginParameters({
+ lambdaConnection: disconnectedState,
+ connectionValidationMode: null,
+ });
+ } catch {
+ // Ignore persistence failure on mount.
+ }
+ } finally {
+ if (!isCancelled()) {
+ setIsMountChecking(false);
+ debugLogger.log('Mount health check finished');
+ }
+ }
+ },
+ [ctx.environment, debugLogger, persistPluginParameters],
+ );
+
+ const runMigrateAndCheck = useCallback(
+ async (isCancelled: () => boolean) => {
+ const snapshot = initialMountRef.current;
+ if (!snapshot) {
+ return;
+ }
+
+ if (snapshot.scheduleNormalization.requiresMigration) {
+ try {
+ await persistPluginParameters({
+ backupSchedule: snapshot.scheduleNormalization.config,
+ });
+ } catch {
+ // Best-effort schedule migration.
+ }
+ }
+
+ const configuredDeploymentUrl = snapshot.url;
+ setIsMountChecking(true);
+
+ if (!configuredDeploymentUrl.trim()) {
+ debugLogger.log(
+ 'Skipping mount health check because no deployment URL is configured',
+ );
+ if (!isCancelled()) {
+ setConnectionTestError(null);
+ setIsMountChecking(false);
+ }
+
+ try {
+ await persistPluginParameters({
+ lambdaConnection: null,
+ connectionValidationMode: null,
+ });
+ } catch {
+ // Ignore persistence errors on mount.
+ }
+
+ return;
+ }
+
+ await runMountHealthCheck({ configuredDeploymentUrl, isCancelled });
+ },
+ [debugLogger, persistPluginParameters, runMountHealthCheck],
+ );
+
+ useEffect(() => {
+ // A StrictMode remount re-enters this effect on the same fiber, so reset the
+ // unmount flag here; a genuine unmount sets it again via the cleanup below
+ // and is never followed by a re-entry.
+ isMountCheckUnmountedRef.current = false;
+
+ if (!hasRunMountCheckRef.current) {
+ hasRunMountCheckRef.current = true;
+ debugLoggerRef.current.log('Config screen mounted');
+ void runMigrateAndCheck(() => isMountCheckUnmountedRef.current);
+ }
+
+ return () => {
+ isMountCheckUnmountedRef.current = true;
+ debugLoggerRef.current.log('Config screen unmounted');
+ };
+ // Must run exactly once per component instance (StrictMode double-invoke
+ // included). Its callbacks close over `ctx`, whose identity changes after
+ // every updatePluginParameters; listing them would re-fire the effect on
+ // every render and, because the effect persists parameters, recreate the
+ // infinite request loop this guard fixes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ void refreshLambdaBackupOverview();
+ }, [refreshLambdaBackupOverview]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ const loadAvailableEnvironments = async () => {
+ const environmentIds = await fetchAvailableEnvironmentIds();
+ if (isCancelled) {
+ return;
+ }
+ setAvailableEnvironmentIds(environmentIds);
+ };
+
+ void loadAvailableEnvironments();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [fetchAvailableEnvironmentIds]);
+
+ const handleUrlChange = useCallback((value: string) => {
+ setUrlInput(value);
+ setConnectionTestError(null);
+ }, []);
+
+ const regenerateSecret = useCallback(() => {
+ setSecretInput(generateAuthSecret());
+ }, []);
+
+ const copySecret = useCallback(async () => {
+ const value = secretInput.trim();
+ if (!value) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(value);
+ ctx.notice('Auth secret copied to clipboard.');
+ } catch {
+ await ctx.alert('Could not copy the secret. Copy it manually.');
+ }
+ }, [ctx, secretInput]);
+
+ const saveSecret = useCallback(async () => {
+ const nextSecret = secretInput.trim();
+ if (!nextSecret) {
+ await ctx.alert('Enter or generate an auth secret before saving.');
+ return;
+ }
+
+ setIsSavingSecret(true);
+ try {
+ const previousSecret = readAuthSecret(params);
+ const secretChanged = nextSecret !== previousSecret;
+ const wasConnected = isConnectionHealthy(params);
+
+ const updates: Record = { lambdaAuthSecret: nextSecret };
+ // Changing a secret invalidates any prior healthy connection: re-gate the
+ // Connect step so the user re-tests against the new secret.
+ if (secretChanged && wasConnected) {
+ updates.lambdaConnection = null;
+ updates.connectionValidationMode = null;
+ }
+
+ await persistPluginParameters(updates);
+ debugLogger.log('Auth secret saved', { secretChanged });
+ ctx.notice('Auth secret saved.');
+ } catch (error) {
+ debugLogger.error('Could not save auth secret', error);
+ await ctx.alert('Could not save the auth secret.');
+ } finally {
+ setIsSavingSecret(false);
+ }
+ }, [ctx, debugLogger, params, persistPluginParameters, secretInput]);
+
+ const saveAndTestConnection = useCallback(async () => {
+ const candidateUrl = urlInput.trim();
+ if (!candidateUrl) {
+ setConnectionTestError({
+ summary: 'Enter your lambda deployment URL.',
+ details: [],
+ });
+ return;
+ }
+
+ const secret = readAuthSecret(params);
+ if (!secret) {
+ setConnectionTestError({
+ summary: MISSING_AUTH_SECRET_MESSAGE,
+ details: [
+ 'Save an auth secret in step 1 first, and set the same value as DATOCMS_BACKUPS_SHARED_SECRET on your deployment.',
+ ],
+ });
+ return;
+ }
+
+ setIsConnecting(true);
+ setConnectionTestError(null);
+
+ try {
+ const verificationResult = await verifyLambdaHealth({
+ baseUrl: candidateUrl,
+ environment: ctx.environment,
+ phase: 'config_connect',
+ lambdaAuthSecret: secret,
+ });
+
+ const connectedState = buildConnectedLambdaConnectionState(
+ verificationResult.endpoint,
+ verificationResult.checkedAt,
+ 'config_connect',
+ );
+
+ // Persist the deployment URL triplet (legacy netlify/vercel keys kept in
+ // lockstep) together with the resulting connection state.
+ await persistPluginParameters({
+ deploymentURL: verificationResult.normalizedBaseUrl,
+ netlifyURL: verificationResult.normalizedBaseUrl,
+ vercelURL: verificationResult.normalizedBaseUrl,
+ lambdaConnection: connectedState,
+ connectionValidationMode: 'health',
+ lambdaAuthSecret: secret,
+ });
+
+ setUrlInput(verificationResult.normalizedBaseUrl);
+ debugLogger.log('Lambda connected successfully', {
+ endpoint: verificationResult.endpoint,
+ });
+ ctx.notice('Lambda function connected successfully.');
+ } catch (error) {
+ if (error instanceof LambdaHealthCheckError) {
+ const disconnectedState = buildDisconnectedLambdaConnectionState(
+ error,
+ candidateUrl,
+ 'config_connect',
+ );
+ debugLogger.warn('Lambda health check failed during connect', error);
+ try {
+ await persistPluginParameters({
+ deploymentURL: candidateUrl,
+ netlifyURL: candidateUrl,
+ vercelURL: candidateUrl,
+ lambdaConnection: disconnectedState,
+ connectionValidationMode: null,
+ });
+ } catch {
+ // Ignore persistence errors while surfacing the error in the UI.
+ }
+ } else {
+ debugLogger.error('Unexpected error while connecting lambda', error);
+ setConnectionTestError({
+ summary: 'Unexpected error while connecting lambda.',
+ details: [`Failure details: ${getErrorMessage(error)}`],
+ });
+ }
+ } finally {
+ setIsConnecting(false);
+ }
+ }, [ctx, debugLogger, params, persistPluginParameters, urlInput]);
+
+ const disconnect = useCallback(async () => {
+ setIsDisconnecting(true);
+ setConnectionTestError(null);
+
+ try {
+ await persistPluginParameters({
+ deploymentURL: '',
+ netlifyURL: '',
+ vercelURL: '',
+ lambdaConnection: null,
+ connectionValidationMode: null,
+ });
+
+ setUrlInput('');
+ setLambdaBackupStatus(undefined);
+ setOverviewError(
+ 'Backup status is unavailable until a Lambda URL is connected.',
+ );
+ debugLogger.log('Lambda disconnected');
+ ctx.notice('Current lambda function has been disconnected.');
+ } catch (error) {
+ debugLogger.error('Could not disconnect current lambda', error);
+ await ctx.alert('Could not disconnect the current lambda function.');
+ } finally {
+ setIsDisconnecting(false);
+ }
+ }, [ctx, debugLogger, persistPluginParameters]);
+
+ const buildPersistedBackupSchedule = useCallback(
+ (normalizedEnabledCadences: BackupCadence[]): BackupScheduleConfig => {
+ const savedSchedule = normalizeBackupScheduleConfig({
+ value: params?.backupSchedule,
+ timezoneFallback: projectTimezone,
+ }).config;
+ const savedCadences = savedSchedule.enabledCadences;
+ const didCadencesChange =
+ savedCadences.length !== normalizedEnabledCadences.length ||
+ savedCadences.some(
+ (cadence, index) => cadence !== normalizedEnabledCadences[index],
+ );
+ return {
+ version: BACKUP_SCHEDULE_VERSION,
+ enabledCadences: normalizedEnabledCadences,
+ timezone: projectTimezone,
+ anchorLocalDate: didCadencesChange
+ ? toLocalDateKey(new Date(), projectTimezone)
+ : savedSchedule.anchorLocalDate,
+ updatedAt: new Date().toISOString(),
+ };
+ },
+ [params?.backupSchedule, projectTimezone],
+ );
+
+ const setCadenceEnabled = useCallback(
+ (cadence: BackupCadence, enabled: boolean) => {
+ setCadenceSelection((current) => {
+ if (enabled) {
+ if (current.includes(cadence)) {
+ return current;
+ }
+ return BACKUP_CADENCES.filter(
+ (candidate) => candidate === cadence || current.includes(candidate),
+ );
+ }
+ return current.filter((candidate) => candidate !== cadence);
+ });
+ },
+ [],
+ );
+
+ const saveSchedule = useCallback(async () => {
+ const normalized = BACKUP_CADENCES.filter((cadence) =>
+ cadenceSelection.includes(cadence),
+ );
+ if (normalized.length === 0) {
+ await ctx.alert('Select at least one backup cadence.');
+ return;
+ }
+
+ setIsSavingSchedule(true);
+ try {
+ const persistedSchedule = buildPersistedBackupSchedule(normalized);
+ await persistPluginParameters({ backupSchedule: persistedSchedule });
+ debugLogger.log('Backup schedule saved', {
+ enabledCadences: normalized,
+ });
+ ctx.notice('Backup schedule saved.');
+
+ const baseUrl = readDeploymentUrl(params);
+ const secret = readAuthSecret(params);
+ if (baseUrl && secret) {
+ await ensureBackupsExistForCadences({
+ baseUrl,
+ lambdaAuthSecret: secret,
+ cadences: normalized,
+ });
+ }
+ } catch (error) {
+ debugLogger.error('Could not save backup schedule', error);
+ await ctx.alert('Could not save the backup schedule.');
+ } finally {
+ setIsSavingSchedule(false);
+ }
+ }, [
+ buildPersistedBackupSchedule,
+ cadenceSelection,
+ ctx,
+ debugLogger,
+ ensureBackupsExistForCadences,
+ params,
+ persistPluginParameters,
+ ]);
+
+ const saveDebug = useCallback(
+ async (enabled: boolean) => {
+ setDebugEnabled(enabled);
+ try {
+ await persistPluginParameters({ debug: enabled });
+ } catch (error) {
+ debugLogger.error('Could not persist debug setting', error);
+ }
+ },
+ [debugLogger, persistPluginParameters],
+ );
+
+ const backupNow = useCallback(
+ async (scope: BackupCadence) => {
+ if (backupNowInFlightCadence) {
+ return;
+ }
+
+ const candidateUrl = readDeploymentUrl(params);
+ const secret = readAuthSecret(params);
+
+ if (!candidateUrl) {
+ setOverviewError('Connect a Lambda URL before running backup now.');
+ return;
+ }
+ if (!secret) {
+ setOverviewError(
+ 'Lambda auth secret is required before running backup now.',
+ );
+ return;
+ }
+
+ setBackupNowInFlightCadence(scope);
+ setOverviewError('');
+
+ try {
+ const result = await triggerLambdaBackupNow({
+ baseUrl: candidateUrl,
+ environment: ctx.environment,
+ scope,
+ lambdaAuthSecret: secret,
+ });
+ ctx.notice(
+ `${getCadenceLabel(scope)} backup created: ${result.createdEnvironmentId}.`,
+ );
+ await refreshLambdaBackupOverview(candidateUrl);
+ await refreshAvailableEnvironments();
+ } catch (error) {
+ setOverviewError(
+ error instanceof Error
+ ? error.message
+ : `Could not trigger ${getCadenceLabel(scope).toLowerCase()} backup.`,
+ );
+ } finally {
+ setBackupNowInFlightCadence(null);
+ }
+ },
+ [
+ backupNowInFlightCadence,
+ ctx,
+ params,
+ refreshAvailableEnvironments,
+ refreshLambdaBackupOverview,
+ ],
+ );
+
+ const onOpenEnvironments = useCallback(async () => {
+ const environmentPrefix = ctx.isEnvironmentPrimary
+ ? ''
+ : `/environments/${ctx.environment}`;
+ await ctx.navigateTo(`${environmentPrefix}/project_settings/environments`);
+ }, [ctx]);
+
+ const canBackupNow =
+ isConnected &&
+ savedSecret.trim().length > 0 &&
+ !isConnecting &&
+ !isMountChecking &&
+ !isDisconnecting;
+
+ const connectionErrorDetails: string[] =
+ !isConnected && connection?.status === 'disconnected'
+ ? getLambdaConnectionErrorDetails(connection)
+ : [];
+
+ return {
+ params,
+ projectTimezone,
+ // saved reads
+ savedSecret,
+ savedUrl,
+ connection,
+ isConnected,
+ connectionErrorDetails,
+ // edit state
+ secretInput,
+ setSecretInput,
+ urlInput,
+ setUrlInput: handleUrlChange,
+ cadenceSelection,
+ setCadenceEnabled,
+ debugEnabled,
+ // handlers
+ saveSecret,
+ regenerateSecret,
+ copySecret,
+ saveAndTestConnection,
+ disconnect,
+ saveSchedule,
+ saveDebug,
+ backupNow,
+ onOpenEnvironments,
+ // activity
+ isSavingSecret,
+ isConnecting,
+ isMountChecking,
+ isDisconnecting,
+ isSavingSchedule,
+ backupNowInFlightCadence,
+ progressMessage,
+ connectionTestError,
+ // overview
+ lambdaBackupStatus,
+ availableEnvironmentIds,
+ overviewError,
+ isLoadingOverview,
+ canBackupNow,
+ };
+};
+
+export type BackupsConfig = ReturnType;
diff --git a/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx b/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx
index e7e595f3..be20c675 100644
--- a/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx
+++ b/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx
@@ -1,1862 +1,119 @@
-import { buildClient } from '@datocms/cma-client-browser';
import type { RenderConfigScreenCtx } from 'datocms-plugin-sdk';
+import { Canvas } from 'datocms-react-ui';
+import { useEffect, useRef, useState } from 'react';
+import { AdvancedSettings } from '../config/AdvancedSettings';
import {
- Button,
- Canvas,
- Form,
- Section,
- SwitchField,
- TextField,
-} from 'datocms-react-ui';
-import {
- type CSSProperties,
- type KeyboardEvent,
- type MouseEvent as ReactMouseEvent,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from 'react';
-import type {
- BackupCadence,
- BackupOverviewRow,
- BackupScheduleConfig,
- ConnectionValidationMode,
- LambdaBackupStatus,
- LambdaConnectionState,
-} from '../types/types';
-import {
- BACKUP_CADENCES,
- BACKUP_SCHEDULE_VERSION,
- getCadenceLabel,
- normalizeBackupScheduleConfig,
- toLocalDateKey,
-} from '../utils/backupSchedule';
-import { buildBackupOverviewRows } from '../utils/buildBackupOverviewRows';
-import { createDebugLogger, isDebugEnabled } from '../utils/debugLogger';
-import {
- DEPLOY_PROVIDER_OPTIONS,
- type DeployProvider,
-} from '../utils/deployProviders';
-import { fetchLambdaBackupStatus } from '../utils/fetchLambdaBackupStatus';
-import { getDeploymentUrlFromParameters } from '../utils/getDeploymentUrlFromParameters';
-import {
- mergePluginParameterUpdates,
- toPluginParameterRecord,
-} from '../utils/pluginParameterMerging';
-import {
- LambdaBackupNowError,
- triggerLambdaBackupNow,
-} from '../utils/triggerLambdaBackupNow';
-import {
- buildConnectedLambdaConnectionState,
- buildDisconnectedLambdaConnectionState,
- getLambdaConnectionErrorDetails,
- LambdaHealthCheckError,
- verifyLambdaHealth,
-} from '../utils/verifyLambdaHealth';
-
-const DEFAULT_CONNECTION_ERROR_SUMMARY =
- 'Could not validate the Automatic Backups deployment.';
-const MISSING_AUTH_SECRET_MESSAGE =
- 'Enter Lambda auth secret before calling lambda endpoints.';
-const DEFAULT_LAMBDA_AUTH_SECRET = 'superSecretToken';
-const BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS = 1200;
-
-const getErrorMessage = (error: unknown): string =>
- error instanceof Error ? error.message : 'Unknown error';
-
-const delay = (ms: number): Promise =>
- new Promise((resolve) => {
- setTimeout(resolve, ms);
- });
-
-async function retryBackupNowAfterDelay({
- baseUrl,
- environment,
- scope,
- lambdaAuthSecret,
- cadence,
-}: {
- baseUrl: string;
- environment: string;
- scope: BackupCadence;
- lambdaAuthSecret: string;
- cadence: BackupCadence;
-}): Promise<{
- success: boolean;
- environmentId?: string;
- errorMessage?: string;
-}> {
- try {
- await delay(BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS);
- const retryResult = await triggerLambdaBackupNow({
- baseUrl,
- environment,
- scope,
- lambdaAuthSecret,
- });
- return { success: true, environmentId: retryResult.createdEnvironmentId };
- } catch (retryError) {
- const retryMessage = getErrorMessage(retryError);
- return {
- success: false,
- errorMessage: `${getCadenceLabel(cadence)}: ${retryMessage}`,
- };
- }
-}
-
-type PluginParameters = Record | undefined;
-
-const toConnectionValidationMode = (
- value: unknown,
-): ConnectionValidationMode | undefined => {
- return value === 'health' ? value : undefined;
-};
-
-const toLambdaConnectionState = (
- value: unknown,
-): LambdaConnectionState | undefined => {
- if (!value || typeof value !== 'object') {
- return undefined;
- }
-
- const candidate = value as Partial;
- if (
- (candidate.status === 'connected' || candidate.status === 'disconnected') &&
- typeof candidate.endpoint === 'string' &&
- typeof candidate.lastCheckedAt === 'string' &&
- (candidate.lastCheckPhase === 'finish_installation' ||
- candidate.lastCheckPhase === 'config_mount' ||
- candidate.lastCheckPhase === 'config_connect')
- ) {
- return candidate as LambdaConnectionState;
- }
-
- return undefined;
-};
-
-const getProjectTimezone = (site: unknown): string => {
- if (
- site &&
- typeof site === 'object' &&
- 'timezone' in site &&
- typeof (site as { timezone?: unknown }).timezone === 'string' &&
- (site as { timezone: string }).timezone.trim()
- ) {
- return (site as { timezone: string }).timezone.trim();
- }
-
- return 'UTC';
-};
-
-const getConnectionErrorSummary = (
- connection?: LambdaConnectionState,
-): string => {
- if (!connection || connection.status !== 'disconnected') {
- return '';
- }
-
- return connection.errorMessage || DEFAULT_CONNECTION_ERROR_SUMMARY;
-};
-
-const getPluginIdFromCtx = (ctx: RenderConfigScreenCtx): string | undefined => {
- const candidate = (ctx.plugin as { id?: unknown } | undefined)?.id;
- return typeof candidate === 'string' && candidate.trim()
- ? candidate.trim()
- : undefined;
-};
-
-const getInitialLambdaAuthSecret = (
- pluginParameters: PluginParameters,
-): string => {
- const hasSecret =
- typeof pluginParameters?.lambdaAuthSecret === 'string' &&
- pluginParameters.lambdaAuthSecret.trim().length > 0;
- return hasSecret
- ? (pluginParameters?.lambdaAuthSecret as string)
- : DEFAULT_LAMBDA_AUTH_SECRET;
-};
-
-const getPingIndicator = ({
- connectionActivityMessage,
- isHealthChecking,
- isConnecting,
- connectionState,
- activeDeploymentUrl,
-}: {
- connectionActivityMessage: string | null;
- isHealthChecking: boolean;
- isConnecting: boolean;
- connectionState: LambdaConnectionState | undefined;
- activeDeploymentUrl: string;
-}): { label: string; color: string } => {
- if (connectionActivityMessage) {
- return { label: connectionActivityMessage, color: 'var(--color--warning-soft--ink)' };
- }
- if (isHealthChecking || isConnecting) {
- return { label: 'Checking ping...', color: 'var(--color--warning-soft--ink)' };
- }
- if (connectionState?.status === 'connected') {
- return {
- label: 'Connected (ping successful)',
- color: 'var(--color--success-soft--ink)',
- };
- }
- if (connectionState?.status === 'disconnected') {
- return { label: 'Disconnected (ping failed)', color: 'var(--color--danger-soft--ink)' };
- }
- if (activeDeploymentUrl) {
- return { label: 'Connection pending', color: 'var(--color--ink-subtle)' };
- }
- return {
- label: 'Disconnected (no lambda URL configured)',
- color: 'var(--color--ink-subtle)',
- };
-};
-
-const getConnectButtonLabel = (
- isConnecting: boolean,
- hasActiveDeploymentUrl: boolean,
-): string => {
- if (isConnecting) {
- return hasActiveDeploymentUrl ? 'Changing Lambda URL...' : 'Connecting...';
- }
- return hasActiveDeploymentUrl ? 'Change Lambda URL' : 'Connect';
-};
-
-const getInitialConnectionErrorSummary = (
- hasInitialConnectionErrorDetails: boolean,
- initialConnectionState: LambdaConnectionState | undefined,
-): string => {
- if (!hasInitialConnectionErrorDetails) {
- return '';
- }
- return getConnectionErrorSummary(initialConnectionState);
-};
-
-const getInitialConnectionErrorDetails = (
- hasInitialConnectionErrorDetails: boolean,
- initialConnectionState: LambdaConnectionState | undefined,
-): string[] => {
- if (!hasInitialConnectionErrorDetails || !initialConnectionState) {
- return [];
- }
- return getLambdaConnectionErrorDetails(initialConnectionState);
-};
-
-const didCadenceSelectionChange = (
- current: BackupCadence[],
- saved: BackupCadence[],
-): boolean => {
- if (current.length !== saved.length) {
- return true;
- }
- return current.some((cadence, index) => cadence !== saved[index]);
-};
-
-const checkHasInitialConnectionErrorDetails = (
- initialDeploymentUrl: string,
- initialConnectionState: LambdaConnectionState | undefined,
-): boolean => {
- if (initialDeploymentUrl.trim().length === 0) {
- return false;
- }
- if (initialConnectionState?.status !== 'disconnected') {
- return false;
- }
- return Boolean(
- initialConnectionState.errorCode ||
- initialConnectionState.errorMessage ||
- initialConnectionState.httpStatus ||
- initialConnectionState.responseSnippet,
- );
-};
-
+ deriveStepStatuses,
+ type SetupStepId,
+} from '../config/deriveStepStatuses';
+import { readEnabledCadences } from '../config/pluginParams';
+import { StatusOverview } from '../config/StatusOverview';
+import { StepConnect } from '../config/StepConnect';
+import { StepSchedule } from '../config/StepSchedule';
+import { StepSection } from '../config/StepSection';
+import { StepSecret } from '../config/StepSecret';
+import { useBackupsConfig } from '../config/useBackupsConfig';
+import { getCadenceLabel } from '../utils/backupSchedule';
+
+/**
+ * Thin orchestrator for the config wizard. Reads the saved plugin parameters as
+ * the single source of truth, derives per-step statuses, and renders the gated
+ * accordion (steps 1–3) plus the always-visible Status overview and Advanced
+ * settings. All state and side effects live in {@link useBackupsConfig}; there
+ * is no global Save button — each step commits its own change.
+ */
export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
- const pluginParameters = ctx.plugin.attributes.parameters as PluginParameters;
- const initialDeploymentUrl = getDeploymentUrlFromParameters(pluginParameters);
- const initialLambdaAuthSecret = getInitialLambdaAuthSecret(pluginParameters);
- const initialDebugEnabled = isDebugEnabled(pluginParameters);
- const initialConnectionState = toLambdaConnectionState(
- pluginParameters?.lambdaConnection,
- );
- const initialValidationMode = toConnectionValidationMode(
- pluginParameters?.connectionValidationMode,
- );
- const projectTimezone = getProjectTimezone(ctx.site);
- const initialScheduleNormalization = normalizeBackupScheduleConfig({
- value: pluginParameters?.backupSchedule,
- timezoneFallback: projectTimezone,
- });
- const initialBackupSchedule = initialScheduleNormalization.config;
-
- const hasInitialConnectionErrorDetails =
- checkHasInitialConnectionErrorDetails(
- initialDeploymentUrl,
- initialConnectionState,
- );
-
- const [enabledCadencesSelection, setEnabledCadencesSelection] = useState<
- BackupCadence[]
- >(initialBackupSchedule.enabledCadences);
- const [debugEnabled, setDebugEnabled] = useState(initialDebugEnabled);
- const [savedFormValues, setSavedFormValues] = useState({
- debugEnabled: initialDebugEnabled,
- lambdaAuthSecret: initialLambdaAuthSecret,
- backupSchedule: initialBackupSchedule,
- });
-
- const [isLoading, setIsLoading] = useState(false);
- const [isHealthChecking, setIsHealthChecking] = useState(false);
- const [isConnecting, setIsConnecting] = useState(false);
- const [isDisconnecting, setIsDisconnecting] = useState(false);
-
- const [deploymentUrlInput, setDeploymentUrlInput] =
- useState(initialDeploymentUrl);
- const [activeDeploymentUrl, setActiveDeploymentUrl] =
- useState(initialDeploymentUrl);
- const [lambdaAuthSecretInput, setLambdaAuthSecretInput] = useState(
- initialLambdaAuthSecret,
- );
- const [connectionState, setConnectionState] = useState<
- LambdaConnectionState | undefined
- >(initialConnectionState);
- const [connectionValidationMode, setConnectionValidationMode] = useState<
- ConnectionValidationMode | undefined
- >(initialValidationMode);
- const [connectionErrorSummary, setConnectionErrorSummary] = useState(
- getInitialConnectionErrorSummary(
- hasInitialConnectionErrorDetails,
- initialConnectionState,
- ),
- );
- const [connectionErrorDetails, setConnectionErrorDetails] = useState<
- string[]
- >(
- getInitialConnectionErrorDetails(
- hasInitialConnectionErrorDetails,
- initialConnectionState,
- ),
- );
- const [showConnectionDetails, setShowConnectionDetails] = useState(false);
- const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
- const [lambdaBackupStatus, setLambdaBackupStatus] = useState<
- LambdaBackupStatus | undefined
- >(undefined);
- const [isLoadingBackupOverview, setIsLoadingBackupOverview] = useState(false);
- const [backupOverviewError, setBackupOverviewError] = useState('');
- const [availableEnvironmentIds, setAvailableEnvironmentIds] = useState<
- string[] | undefined
- >(undefined);
- const [backupNowInFlightCadence, setBackupNowInFlightCadence] =
- useState(null);
- const [connectionActivityMessage, setConnectionActivityMessage] = useState<
- string | null
- >(null);
- const [isDeployProviderMenuOpen, setIsDeployProviderMenuOpen] =
- useState(false);
- const debugLogger = createDebugLogger(debugEnabled, 'ConfigScreen');
- const persistQueueRef = useRef>(Promise.resolve());
- const deployProviderMenuRef = useRef(null);
-
- const persistPluginParameters = useCallback(
- async (updates: Record) => {
- const persistTask = async () => {
- let latestParameters = toPluginParameterRecord(
- ctx.plugin.attributes.parameters,
- );
- const pluginId = getPluginIdFromCtx(ctx);
-
- if (pluginId && ctx.currentUserAccessToken) {
- try {
- const client = buildClient({
- apiToken: ctx.currentUserAccessToken,
- environment: ctx.environment,
- baseUrl: ctx.cmaBaseUrl,
- });
- const plugin = await client.plugins.find(pluginId);
- latestParameters = toPluginParameterRecord(plugin.parameters);
- } catch (error) {
- debugLogger.warn(
- 'Falling back to local plugin parameters because authoritative read failed',
- {
- pluginId,
- error: getErrorMessage(error),
- },
- );
- }
- }
-
- await ctx.updatePluginParameters(
- mergePluginParameterUpdates(latestParameters, updates),
- );
- };
-
- const queuedPersist = persistQueueRef.current.then(
- persistTask,
- persistTask,
- );
- persistQueueRef.current = queuedPersist.then(
- () => undefined,
- () => undefined,
- );
- return queuedPersist;
- },
- [ctx, debugLogger],
- );
-
- const clearConnectionErrorState = useCallback(() => {
- setConnectionErrorSummary('');
- setConnectionErrorDetails([]);
- setShowConnectionDetails(false);
- }, []);
-
- const getNormalizedLambdaAuthSecret = () => lambdaAuthSecretInput.trim();
-
- const applyDisconnectedState = useCallback((state: LambdaConnectionState) => {
- setConnectionState(state);
- setConnectionErrorSummary(getConnectionErrorSummary(state));
- setConnectionErrorDetails(getLambdaConnectionErrorDetails(state));
- setShowConnectionDetails(false);
- }, []);
-
- const refreshLambdaBackupOverview = useCallback(
- async (baseUrl?: string) => {
- const candidateUrl = (baseUrl || activeDeploymentUrl).trim();
- const normalizedLambdaAuthSecret =
- savedFormValues.lambdaAuthSecret.trim();
- const shouldFetch =
- candidateUrl.length > 0 && normalizedLambdaAuthSecret.length > 0;
-
- if (!shouldFetch) {
- setLambdaBackupStatus(undefined);
- setBackupOverviewError(
- candidateUrl.length === 0
- ? 'Lambda status is unavailable until the saved Lambda URL is connected with a healthy ping.'
- : 'Lambda status is unavailable until Lambda auth secret is configured and saved.',
- );
- setIsLoadingBackupOverview(false);
- return;
- }
-
- setIsLoadingBackupOverview(true);
- setBackupOverviewError('');
-
- try {
- const status = await fetchLambdaBackupStatus({
- baseUrl: candidateUrl,
- environment: ctx.environment,
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- });
- setLambdaBackupStatus(status);
- } catch (error) {
- setLambdaBackupStatus(undefined);
- setBackupOverviewError(
- error instanceof Error
- ? error.message
- : 'Could not load backup overview from lambda status endpoint.',
- );
- } finally {
- setIsLoadingBackupOverview(false);
- }
- },
- [activeDeploymentUrl, ctx.environment, savedFormValues.lambdaAuthSecret],
- );
-
- const fetchAvailableEnvironmentIds = useCallback(async () => {
- if (!ctx.currentUserAccessToken) {
- return undefined;
- }
-
- try {
- const client = buildClient({
- apiToken: ctx.currentUserAccessToken,
- environment: ctx.environment,
- baseUrl: ctx.cmaBaseUrl,
- });
- const environments = await client.environments.list();
- return environments
- .map((environment) => environment.id)
- .filter((id) => typeof id === 'string' && id.trim().length > 0);
- } catch {
- return undefined;
- }
- }, [ctx.currentUserAccessToken, ctx.environment, ctx.cmaBaseUrl]);
-
- const refreshAvailableEnvironments = useCallback(async () => {
- const environmentIds = await fetchAvailableEnvironmentIds();
- setAvailableEnvironmentIds(environmentIds);
- }, [fetchAvailableEnvironmentIds]);
-
- const triggerBackupForSingleCadence = useCallback(
- async ({
- baseUrl,
- lambdaAuthSecret,
- cadence,
- reason,
- setBackupNowInFlightCadence: setInFlight,
- }: {
- baseUrl: string;
- lambdaAuthSecret: string;
- cadence: BackupCadence;
- reason: 'connect' | 'schedule';
- setBackupNowInFlightCadence: (cadence: BackupCadence | null) => void;
- }): Promise<{
- success: boolean;
- environmentId?: string;
- errorMessage?: string;
- }> => {
- setInFlight(cadence);
- try {
- const result = await triggerLambdaBackupNow({
- baseUrl,
- environment: ctx.environment,
- scope: cadence,
- lambdaAuthSecret,
- });
- return { success: true, environmentId: result.createdEnvironmentId };
- } catch (error) {
- const isScheduleRaceCondition =
- reason === 'schedule' &&
- error instanceof LambdaBackupNowError &&
- error.code === 'HTTP' &&
- error.httpStatus === 409;
-
- if (isScheduleRaceCondition) {
- return retryBackupNowAfterDelay({
- baseUrl,
- environment: ctx.environment,
- scope: cadence,
- lambdaAuthSecret,
- cadence,
- });
- }
-
- return {
- success: false,
- errorMessage: `${getCadenceLabel(cadence)}: ${getErrorMessage(error)}`,
- };
- }
- },
- [ctx.environment],
- );
-
- const createMissingBackupsSequentially = useCallback(
- async ({
- baseUrl,
- lambdaAuthSecret,
- cadencesMissingEnvironment,
- reason,
- }: {
- baseUrl: string;
- lambdaAuthSecret: string;
- cadencesMissingEnvironment: BackupCadence[];
- reason: 'connect' | 'schedule';
- }): Promise<{
- createdEnvironmentIds: string[];
- failedCadences: string[];
- }> => {
- const processCadence = async (
- accumulated: {
- createdEnvironmentIds: string[];
- failedCadences: string[];
- },
- cadence: BackupCadence,
- ): Promise<{
- createdEnvironmentIds: string[];
- failedCadences: string[];
- }> => {
- if (reason === 'connect') {
- setConnectionActivityMessage(
- `Connected. Creating ${getCadenceLabel(cadence).toLowerCase()} backup...`,
- );
- }
-
- const outcome = await triggerBackupForSingleCadence({
- baseUrl,
- lambdaAuthSecret,
- cadence,
- reason,
- setBackupNowInFlightCadence,
- });
-
- if (outcome.success && outcome.environmentId) {
- accumulated.createdEnvironmentIds.push(outcome.environmentId);
- } else if (outcome.errorMessage) {
- accumulated.failedCadences.push(outcome.errorMessage);
- }
-
- return accumulated;
- };
-
- const initialAccumulator = {
- createdEnvironmentIds: [] as string[],
- failedCadences: [] as string[],
- };
-
- return cadencesMissingEnvironment.reduce(
- (chainedPromise, cadence) =>
- chainedPromise.then((acc) => processCadence(acc, cadence)),
- Promise.resolve(initialAccumulator),
- );
- },
- [triggerBackupForSingleCadence],
- );
-
- const reportBackupCreationResults = useCallback(
- ({
- createdEnvironmentIds,
- failedCadences,
- reason,
- }: {
- createdEnvironmentIds: string[];
- failedCadences: string[];
- reason: 'connect' | 'schedule';
- }) => {
- if (createdEnvironmentIds.length > 0) {
- const reasonLabel =
- reason === 'connect'
- ? 'after connecting Lambda'
- : 'for newly added schedule cadences';
- const plural = createdEnvironmentIds.length > 1 ? 's' : '';
- ctx.notice(
- `Created ${createdEnvironmentIds.length} backup environment${plural} ${reasonLabel}.`,
- );
- }
-
- if (failedCadences.length > 0) {
- setBackupOverviewError(
- `Some automatic backup creations failed: ${failedCadences.join(' | ')}`,
- );
- }
- },
- [ctx],
- );
-
- const ensureBackupsExistForCadences = useCallback(
- async ({
- baseUrl,
- lambdaAuthSecret,
- cadences,
- reason,
- }: {
- baseUrl: string;
- lambdaAuthSecret: string;
- cadences: BackupCadence[];
- reason: 'connect' | 'schedule';
- }) => {
- if (cadences.length === 0) {
- return;
- }
-
- try {
- const status = await fetchLambdaBackupStatus({
- baseUrl,
- environment: ctx.environment,
- lambdaAuthSecret,
- });
-
- const cadencesMissingEnvironment = cadences.filter((cadence) => {
- const slot = status.slots[cadence];
- return !slot?.lastBackupAt;
- });
-
- if (cadencesMissingEnvironment.length === 0) {
- await refreshLambdaBackupOverview(baseUrl);
- await refreshAvailableEnvironments();
- return;
- }
-
- if (reason === 'connect') {
- setConnectionActivityMessage(
- 'Connected. Creating initial backups...',
- );
- }
-
- const backupCreationResults = await createMissingBackupsSequentially({
- baseUrl,
- lambdaAuthSecret,
- cadencesMissingEnvironment,
- reason,
- });
-
- reportBackupCreationResults({ ...backupCreationResults, reason });
-
- await refreshLambdaBackupOverview(baseUrl);
- await refreshAvailableEnvironments();
- } catch (error) {
- setBackupOverviewError(
- error instanceof Error
- ? error.message
- : 'Could not automatically create missing backup environments.',
- );
- } finally {
- if (reason === 'connect') {
- setConnectionActivityMessage(null);
- }
- setBackupNowInFlightCadence(null);
- }
- },
- [
- ctx,
- createMissingBackupsSequentially,
- refreshAvailableEnvironments,
- refreshLambdaBackupOverview,
- reportBackupCreationResults,
- ],
- );
-
- const runMountHealthCheck = useCallback(
- async ({
- configuredDeploymentUrl,
- isCancelled,
- }: {
- configuredDeploymentUrl: string;
- isCancelled: () => boolean;
- }) => {
- const normalizedLambdaAuthSecret = initialLambdaAuthSecret.trim();
-
- if (!normalizedLambdaAuthSecret) {
- const mountHealthEndpoint = `${configuredDeploymentUrl.replace(/\/+$/, '')}/api/datocms/plugin-health`;
- const disconnectedState = buildDisconnectedLambdaConnectionState(
- new LambdaHealthCheckError({
- code: 'MISSING_AUTH_SECRET',
- message: MISSING_AUTH_SECRET_MESSAGE,
- phase: 'config_mount',
- endpoint: mountHealthEndpoint,
- }),
- configuredDeploymentUrl,
- 'config_mount',
- );
- applyDisconnectedState(disconnectedState);
- setIsHealthChecking(false);
- return;
- }
-
- debugLogger.log('Running mount health check', {
- configuredDeploymentUrl,
- phase: 'config_mount',
- });
-
- try {
- const verificationResult = await verifyLambdaHealth({
- baseUrl: configuredDeploymentUrl,
- environment: ctx.environment,
- phase: 'config_mount',
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- });
-
- if (isCancelled()) {
- return;
- }
-
- const connectedState = buildConnectedLambdaConnectionState(
- verificationResult.endpoint,
- verificationResult.checkedAt,
- 'config_mount',
- );
-
- setConnectionState(connectedState);
- setDeploymentUrlInput(verificationResult.normalizedBaseUrl);
- setActiveDeploymentUrl(verificationResult.normalizedBaseUrl);
- setConnectionValidationMode('health');
- clearConnectionErrorState();
-
- await persistPluginParameters({
- deploymentURL: verificationResult.normalizedBaseUrl,
- netlifyURL: verificationResult.normalizedBaseUrl,
- vercelURL: verificationResult.normalizedBaseUrl,
- lambdaConnection: connectedState,
- connectionValidationMode: 'health',
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- });
- } catch (error) {
- if (isCancelled()) {
- return;
- }
-
- const disconnectedState = buildDisconnectedLambdaConnectionState(
- error,
- configuredDeploymentUrl,
- 'config_mount',
- );
- debugLogger.warn('Mount health check failed', disconnectedState);
- applyDisconnectedState(disconnectedState);
-
- try {
- await persistPluginParameters({
- lambdaConnection: disconnectedState,
- connectionValidationMode: null,
- });
- } catch {
- // Ignore persistence failure on mount.
- }
- } finally {
- if (!isCancelled()) {
- setIsHealthChecking(false);
- debugLogger.log('Mount health check finished');
- }
- }
- },
- [
- applyDisconnectedState,
- clearConnectionErrorState,
- ctx.environment,
- debugLogger,
- initialLambdaAuthSecret,
- persistPluginParameters,
- ],
- );
-
- const runMigrateAndCheck = useCallback(
- async (isCancelled: () => boolean) => {
- if (initialScheduleNormalization.requiresMigration) {
- try {
- await persistPluginParameters({
- backupSchedule: initialBackupSchedule,
- });
- } catch {
- // Best-effort schedule migration.
- }
- }
-
- const configuredDeploymentUrl = initialDeploymentUrl;
- setIsHealthChecking(true);
-
- if (!configuredDeploymentUrl.trim()) {
- debugLogger.log(
- 'Skipping mount health check because no deployment URL is configured',
- );
- if (!isCancelled()) {
- setConnectionState(undefined);
- setConnectionValidationMode(undefined);
- clearConnectionErrorState();
- setIsHealthChecking(false);
- }
-
- try {
- await persistPluginParameters({
- lambdaConnection: null,
- connectionValidationMode: null,
- });
- } catch {
- // Ignore persistence errors on mount.
- }
-
- return;
- }
-
- await runMountHealthCheck({ configuredDeploymentUrl, isCancelled });
- },
- [
- clearConnectionErrorState,
- debugLogger,
- initialBackupSchedule,
- initialDeploymentUrl,
- initialScheduleNormalization.requiresMigration,
- persistPluginParameters,
- runMountHealthCheck,
- ],
- );
-
+ const config = useBackupsConfig(ctx);
+ const { params } = config;
+ const statuses = deriveStepStatuses(params);
+ const { currentStep } = statuses;
+
+ // The current/error step auto-expands; users can [Edit] an `ok` step to
+ // override. Syncing to `currentStep` (tracked via a ref) follows the wizard
+ // as saves advance it, without clobbering manual edits.
+ const [expandedStep, setExpandedStep] = useState(
+ currentStep,
+ );
+ const previousCurrentStepRef = useRef(currentStep);
useEffect(() => {
- let cancelled = false;
- const isCancelled = () => cancelled;
-
- debugLogger.log('Config screen mounted', {
- initialDeploymentUrl,
- initialValidationMode,
- hasInitialConnectionState: Boolean(initialConnectionState),
- debugEnabled,
- });
-
- void runMigrateAndCheck(isCancelled);
-
- return () => {
- cancelled = true;
- debugLogger.log('Config screen unmounted');
- };
- }, [
- debugEnabled,
- debugLogger,
- initialConnectionState,
- initialDeploymentUrl,
- initialValidationMode,
- runMigrateAndCheck,
- ]);
-
- useEffect(() => {
- void refreshLambdaBackupOverview();
- }, [refreshLambdaBackupOverview]);
-
- useEffect(() => {
- let isCancelled = false;
-
- const loadAvailableEnvironments = async () => {
- const environmentIds = await fetchAvailableEnvironmentIds();
- if (isCancelled) {
- return;
- }
- setAvailableEnvironmentIds(environmentIds);
- };
-
- void loadAvailableEnvironments();
-
- return () => {
- isCancelled = true;
- };
- }, [fetchAvailableEnvironmentIds]);
-
- useEffect(() => {
- if (!isDeployProviderMenuOpen) {
- return undefined;
- }
-
- const handleOutsideInteraction = (event: MouseEvent | TouchEvent) => {
- const target = event.target;
- if (!(target instanceof Node)) {
- return;
- }
-
- if (!deployProviderMenuRef.current?.contains(target)) {
- setIsDeployProviderMenuOpen(false);
- }
- };
-
- document.addEventListener('mousedown', handleOutsideInteraction);
- document.addEventListener('touchstart', handleOutsideInteraction);
-
- return () => {
- document.removeEventListener('mousedown', handleOutsideInteraction);
- document.removeEventListener('touchstart', handleOutsideInteraction);
- };
- }, [isDeployProviderMenuOpen]);
-
- useEffect(() => {
- if (isConnecting || isHealthChecking || isDisconnecting) {
- setIsDeployProviderMenuOpen(false);
- }
- }, [isConnecting, isHealthChecking, isDisconnecting]);
-
- const handleConnectLambdaError = useCallback(
- async (error: unknown, candidateUrl: string) => {
- if (error instanceof LambdaHealthCheckError) {
- debugLogger.warn('Lambda health check failed during connect', error);
- const disconnectedState = buildDisconnectedLambdaConnectionState(
- error,
- candidateUrl,
- 'config_connect',
- );
- applyDisconnectedState(disconnectedState);
-
- try {
- await persistPluginParameters({
- lambdaConnection: disconnectedState,
- connectionValidationMode: null,
- });
- } catch {
- // Ignore persistence errors while showing errors in UI.
- }
- } else {
- debugLogger.error('Unexpected error while connecting lambda', error);
- setConnectionErrorSummary('Unexpected error while connecting lambda.');
- setConnectionErrorDetails([
- 'Unexpected error while connecting lambda.',
- `Failure details: ${error instanceof Error ? error.message : 'Unknown error'}`,
- ]);
- setShowConnectionDetails(false);
- }
- },
- [applyDisconnectedState, debugLogger, persistPluginParameters],
- );
-
- const connectLambdaHandler = async () => {
- const candidateUrl = deploymentUrlInput.trim();
- if (!candidateUrl) {
- setConnectionErrorSummary('Enter your lambda deployment URL.');
- setConnectionErrorDetails([]);
- setShowConnectionDetails(false);
- return;
- }
-
- const normalizedLambdaAuthSecret = getNormalizedLambdaAuthSecret();
- if (!normalizedLambdaAuthSecret) {
- setConnectionErrorSummary(MISSING_AUTH_SECRET_MESSAGE);
- setConnectionErrorDetails([
- MISSING_AUTH_SECRET_MESSAGE,
- 'Set the same value as DATOCMS_BACKUPS_SHARED_SECRET configured in the lambda deployment.',
- ]);
- setShowConnectionDetails(false);
- return;
- }
-
- debugLogger.log('Connecting lambda deployment', { candidateUrl });
- setConnectionActivityMessage(null);
- setIsConnecting(true);
- clearConnectionErrorState();
-
- try {
- const verificationResult = await verifyLambdaHealth({
- baseUrl: candidateUrl,
- environment: ctx.environment,
- phase: 'config_connect',
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- });
-
- const connectedState = buildConnectedLambdaConnectionState(
- verificationResult.endpoint,
- verificationResult.checkedAt,
- 'config_connect',
- );
-
- setConnectionState(connectedState);
- setDeploymentUrlInput(verificationResult.normalizedBaseUrl);
- setActiveDeploymentUrl(verificationResult.normalizedBaseUrl);
- setConnectionValidationMode('health');
- clearConnectionErrorState();
-
- await persistPluginParameters({
- deploymentURL: verificationResult.normalizedBaseUrl,
- netlifyURL: verificationResult.normalizedBaseUrl,
- vercelURL: verificationResult.normalizedBaseUrl,
- lambdaConnection: connectedState,
- connectionValidationMode: 'health',
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- });
-
- debugLogger.log('Lambda connected successfully', {
- endpoint: verificationResult.endpoint,
- normalizedBaseUrl: verificationResult.normalizedBaseUrl,
- mode: 'health',
- });
- ctx.notice('Lambda function connected successfully.');
- await ensureBackupsExistForCadences({
- baseUrl: verificationResult.normalizedBaseUrl,
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- cadences: savedFormValues.backupSchedule.enabledCadences,
- reason: 'connect',
- });
- } catch (error) {
- await handleConnectLambdaError(error, candidateUrl);
- } finally {
- setConnectionActivityMessage(null);
- setIsConnecting(false);
- }
- };
-
- const disconnectCurrentLambdaHandler = async () => {
- debugLogger.log('Disconnecting lambda deployment', {
- activeDeploymentUrl,
- });
- setIsDisconnecting(true);
- clearConnectionErrorState();
-
- try {
- await persistPluginParameters({
- deploymentURL: '',
- netlifyURL: '',
- vercelURL: '',
- lambdaConnection: null,
- connectionValidationMode: null,
- });
-
- setDeploymentUrlInput('');
- setActiveDeploymentUrl('');
- setConnectionState(undefined);
- setConnectionValidationMode(undefined);
- setLambdaBackupStatus(undefined);
- setConnectionActivityMessage(null);
- setBackupOverviewError(
- 'Lambda status is unavailable until the saved Lambda URL is connected with a healthy ping.',
- );
- debugLogger.log('Lambda disconnected');
- ctx.notice('Current lambda function has been disconnected.');
- } catch (error) {
- debugLogger.error('Could not disconnect current lambda', error);
- setConnectionErrorSummary('Could not disconnect the current lambda.');
- setConnectionErrorDetails([
- 'Could not disconnect the current lambda function.',
- ]);
- setShowConnectionDetails(false);
- await ctx.alert('Could not disconnect the current lambda function.');
- } finally {
- setIsDisconnecting(false);
- }
- };
-
- const buildPersistedBackupSchedule = useCallback(
- (normalizedEnabledCadences: BackupCadence[]): BackupScheduleConfig => {
- const savedCadences = savedFormValues.backupSchedule.enabledCadences;
- const didCadencesChange =
- savedCadences.length !== normalizedEnabledCadences.length ||
- savedCadences.some(
- (cadence, index) => cadence !== normalizedEnabledCadences[index],
- );
- return {
- version: BACKUP_SCHEDULE_VERSION,
- enabledCadences: normalizedEnabledCadences,
- timezone: projectTimezone,
- anchorLocalDate: didCadencesChange
- ? toLocalDateKey(new Date(), projectTimezone)
- : savedFormValues.backupSchedule.anchorLocalDate,
- updatedAt: new Date().toISOString(),
- };
- },
- [projectTimezone, savedFormValues.backupSchedule],
- );
-
- const saveSettingsHandler = async () => {
- const normalizedEnabledCadences = BACKUP_CADENCES.filter((cadence) =>
- enabledCadencesSelection.includes(cadence),
- );
- const normalizedLambdaAuthSecret = getNormalizedLambdaAuthSecret();
-
- if (normalizedEnabledCadences.length === 0) {
- await ctx.alert('Select at least one backup cadence.');
- return;
- }
-
- debugLogger.log('Saving plugin settings', {
- debugEnabled,
- activeDeploymentUrl,
- connectionStatus: connectionState?.status,
- connectionValidationMode,
- enabledCadences: normalizedEnabledCadences,
- timezone: projectTimezone,
- });
-
- const hasConnectedLambdaForSave =
- activeDeploymentUrl.trim().length > 0 &&
- normalizedLambdaAuthSecret.length > 0 &&
- connectionState?.status === 'connected' &&
- connectionValidationMode === 'health' &&
- !isHealthChecking &&
- !isConnecting;
-
- if (!hasConnectedLambdaForSave) {
- await ctx.alert(
- 'Cannot save unless Lambda URL and Lambda auth secret are configured and ping status is Connected.',
- );
- return;
- }
-
- setIsLoading(true);
-
- try {
- const persistedDeploymentUrl = activeDeploymentUrl.trim();
- const persistedConnectionState = connectionState ?? null;
- const persistedValidationMode: ConnectionValidationMode | null =
- connectionValidationMode ?? null;
- const persistedBackupSchedule = buildPersistedBackupSchedule(
- normalizedEnabledCadences,
- );
-
- await persistPluginParameters({
- debug: debugEnabled,
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- deploymentURL: persistedDeploymentUrl,
- netlifyURL: persistedDeploymentUrl,
- vercelURL: persistedDeploymentUrl,
- lambdaConnection: persistedConnectionState,
- connectionValidationMode: persistedValidationMode,
- backupSchedule: persistedBackupSchedule,
- });
-
- setSavedFormValues({
- debugEnabled,
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- backupSchedule: persistedBackupSchedule,
- });
-
- if (normalizedEnabledCadences.length > 0) {
- await ensureBackupsExistForCadences({
- baseUrl: persistedDeploymentUrl,
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- cadences: normalizedEnabledCadences,
- reason: 'schedule',
- });
- }
-
- ctx.notice(
- `Settings saved. Runtime mode: Lambda (cron). Debug logging is ${
- debugEnabled ? 'enabled' : 'disabled'
- }.`,
- );
- } catch (error) {
- debugLogger.error('Could not save plugin settings', error);
- await ctx.alert('Could not save plugin settings.');
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleDeployProviderClick = (provider: DeployProvider) => {
- const option = DEPLOY_PROVIDER_OPTIONS.find(
- (candidate) => candidate.provider === provider,
- );
- if (!option) {
- return;
- }
-
- setIsDeployProviderMenuOpen(false);
- debugLogger.log('Opening deploy provider', { provider, url: option.url });
- window.open(option.url, '_blank', 'noopener,noreferrer');
- };
-
- const handleDeployProviderMenuKeyDown = (
- event: KeyboardEvent,
- ) => {
- if (event.key === 'Escape') {
- event.preventDefault();
- setIsDeployProviderMenuOpen(false);
- }
- };
-
- const setCadenceEnabled = (cadence: BackupCadence, enabled: boolean) => {
- setEnabledCadencesSelection((current) => {
- if (enabled) {
- if (current.includes(cadence)) {
- return current;
- }
- return BACKUP_CADENCES.filter(
- (candidate) => candidate === cadence || current.includes(candidate),
- );
- }
-
- return current.filter((candidate) => candidate !== cadence);
- });
- };
-
- const openEnvironmentsSettings = async (
- event: ReactMouseEvent,
- ) => {
- event.preventDefault();
- const environmentPrefix = ctx.isEnvironmentPrimary
- ? ''
- : `/environments/${ctx.environment}`;
- await ctx.navigateTo(`${environmentPrefix}/project_settings/environments`);
- };
-
- const triggerBackupNowForCadence = async (scope: BackupCadence) => {
- if (backupNowInFlightCadence) {
- return;
- }
-
- const candidateUrl = activeDeploymentUrl.trim();
- const normalizedLambdaAuthSecret = savedFormValues.lambdaAuthSecret.trim();
-
- if (!candidateUrl) {
- setBackupOverviewError('Connect a Lambda URL before running backup now.');
+ const previous = previousCurrentStepRef.current;
+ if (previous === currentStep) {
return;
}
+ previousCurrentStepRef.current = currentStep;
- if (!normalizedLambdaAuthSecret) {
- setBackupOverviewError(
- 'Lambda auth secret is required before running backup now.',
- );
+ // Right after the secret is saved (secret → connect) with nothing deployed
+ // yet, keep step 1 expanded so its deploy menu + paste callout stay visible;
+ // the user advances to step 2 when they have a deployed URL in hand.
+ const justSavedSecretBeforeDeploy =
+ previous === 'secret' && currentStep === 'connect' && !config.savedUrl;
+ if (justSavedSecretBeforeDeploy) {
return;
}
- setBackupNowInFlightCadence(scope);
- setBackupOverviewError('');
-
- try {
- const result = await triggerLambdaBackupNow({
- baseUrl: candidateUrl,
- environment: ctx.environment,
- scope,
- lambdaAuthSecret: normalizedLambdaAuthSecret,
- });
-
- ctx.notice(
- `${getCadenceLabel(scope)} backup created: ${result.createdEnvironmentId}.`,
- );
- await refreshLambdaBackupOverview(candidateUrl);
- await refreshAvailableEnvironments();
- } catch (error) {
- setBackupOverviewError(
- error instanceof Error
- ? error.message
- : `Could not trigger ${getCadenceLabel(scope).toLowerCase()} backup.`,
- );
- } finally {
- setBackupNowInFlightCadence(null);
- }
- };
-
- const hasActiveDeploymentUrl = activeDeploymentUrl.trim().length > 0;
-
- const pingIndicator = getPingIndicator({
- connectionActivityMessage,
- isHealthChecking,
- isConnecting,
- connectionState,
- activeDeploymentUrl,
- });
-
- const connectButtonLabel = getConnectButtonLabel(
- isConnecting,
- hasActiveDeploymentUrl,
- );
- const disconnectButtonLabel = isDisconnecting
- ? 'Disconnecting...'
- : 'Disconnect';
- const isDeployProviderMenuDisabled =
- isConnecting || isHealthChecking || isDisconnecting;
- const deployProviderMenuId = 'deploy-provider-menu';
-
- const lambdaActionButtonStyle: CSSProperties = {
- width: '100%',
- height: '40px',
- fontSize: 'var(--font-size-m)',
- fontWeight: 500,
- lineHeight: '1',
- padding: '0 var(--spacing-m)',
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- boxSizing: 'border-box',
- flex: '1 1 0',
- whiteSpace: 'nowrap',
- };
-
- const deployProviderMenuContainerStyle: CSSProperties = {
- position: 'relative',
- flex: '1 1 0',
- };
-
- const deployProviderMenuStyle: CSSProperties = {
- position: 'absolute',
- top: 'calc(100% + var(--spacing-xs))',
- left: 0,
- zIndex: 1000,
- minWidth: '180px',
- width: '100%',
- border: '1px solid var(--color--border)',
- borderRadius: '6px',
- background: 'var(--color--surface)',
- boxShadow: '0 8px 24px rgb(0 0 0 / 12%)',
- padding: 'var(--spacing-xs) 0',
- };
-
- const deployProviderOptionStyle: CSSProperties = {
- display: 'block',
- width: '100%',
- border: 0,
- background: 'transparent',
- color: 'var(--color--ink)',
- cursor: 'pointer',
- font: 'inherit',
- fontSize: 'var(--font-size-s)',
- lineHeight: '1.3',
- padding: 'var(--spacing-s) var(--spacing-m)',
- textAlign: 'left',
- };
-
- const cardStyle = {
- border: '1px solid var(--color--border)',
- borderRadius: '6px',
- background: 'var(--color--surface)',
- padding: 'var(--spacing-l)',
- marginBottom: 'var(--spacing-l)',
- textAlign: 'left' as const,
- };
-
- const subtleTextStyle = {
- margin: 0,
- color: 'var(--color--ink-subtle)',
- fontSize: 'var(--font-size-xs)',
- };
-
- const infoTextStyle = {
- marginTop: 0,
- marginBottom: 'var(--spacing-s)',
- color: 'var(--color--ink)',
- fontSize: 'var(--font-size-s)',
- };
-
- const backupScheduleCardStyle: CSSProperties = {
- ...cardStyle,
- paddingTop: 'var(--spacing-m)',
- paddingBottom: 'var(--spacing-m)',
- };
-
- const backupScheduleTitleStyle: CSSProperties = {
- marginTop: 0,
- marginBottom: 'var(--spacing-m)',
- fontSize: 'var(--font-size-l)',
- };
-
- const backupScheduleInfoTextStyle: CSSProperties = {
- marginTop: 0,
- marginBottom: 'var(--spacing-m)',
- color: 'var(--color--ink)',
- fontSize: '12px',
- lineHeight: '1.35',
- };
-
- const backupScheduleCadenceGridStyle: CSSProperties = {
- display: 'grid',
- gap: 'var(--spacing-s)',
- };
-
- const advancedSettingsStyle: CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- gap: 'var(--spacing-m)',
- };
-
- const overviewGridStyle: CSSProperties = {
- display: 'grid',
- gap: 'var(--spacing-s)',
- };
-
- const overviewRowStyle: CSSProperties = {
- border: '1px solid var(--color--border)',
- borderRadius: '6px',
- padding: 'var(--spacing-m)',
- background: 'var(--color--surface)',
- display: 'grid',
- gridTemplateColumns: 'minmax(0, 1fr) auto',
- columnGap: 'var(--spacing-m)',
- alignItems: 'center',
- };
-
- const overviewRowHeaderStyle: CSSProperties = {
- display: 'flex',
- alignItems: 'center',
- gap: 'var(--spacing-s)',
- marginBottom: 'var(--spacing-s)',
- };
-
- const overviewRowContentStyle: CSSProperties = {
- minWidth: 0,
- };
-
- const overviewRowLabelStyle: CSSProperties = {
- marginTop: 0,
- marginBottom: 0,
- fontSize: 'var(--font-size-m)',
- };
-
- const overviewRowInfoStyle: CSSProperties = {
- margin: 0,
- fontSize: 'var(--font-size-s)',
- };
-
- const overviewRowActionStyle: CSSProperties = {
- alignSelf: 'center',
- minWidth: '140px',
- };
+ setExpandedStep(currentStep);
+ }, [currentStep, config.savedUrl]);
- const switchFieldNoHintGapStyle = {
- '--spacing-s': '0',
- } as CSSProperties;
+ const toggleStep = (step: SetupStepId) =>
+ setExpandedStep((current) => (current === step ? null : step));
- const switchFieldNoHintGapStyleWithExtraSpacing = {
- ...switchFieldNoHintGapStyle,
- marginBottom: '0.25rem',
- } as CSSProperties;
-
- const normalizedCadenceSelection = BACKUP_CADENCES.filter((cadence) =>
- enabledCadencesSelection.includes(cadence),
- );
- const hasCadenceSelectionChanged = didCadenceSelectionChange(
- normalizedCadenceSelection,
- savedFormValues.backupSchedule.enabledCadences,
- );
- const hasLambdaAuthSecret = lambdaAuthSecretInput.trim().length > 0;
- const hasSavedLambdaAuthSecret =
- savedFormValues.lambdaAuthSecret.trim().length > 0;
- const hasLambdaAuthSecretChanged =
- lambdaAuthSecretInput.trim() !== savedFormValues.lambdaAuthSecret.trim();
- const hasUnsavedChanges =
- debugEnabled !== savedFormValues.debugEnabled ||
- hasLambdaAuthSecretChanged ||
- hasCadenceSelectionChanged;
-
- const canSaveWithLambdaMode =
- hasActiveDeploymentUrl &&
- hasLambdaAuthSecret &&
- connectionState?.status === 'connected' &&
- connectionValidationMode === 'health' &&
- !isHealthChecking &&
- !isConnecting;
-
- const canTriggerBackupNow =
- hasActiveDeploymentUrl &&
- hasSavedLambdaAuthSecret &&
- connectionState?.status === 'connected' &&
- connectionValidationMode === 'health' &&
- !isHealthChecking &&
- !isConnecting &&
- !isDisconnecting;
-
- const savedBackupSchedule = savedFormValues.backupSchedule;
- const backupOverviewRows: BackupOverviewRow[] = buildBackupOverviewRows({
- scheduleConfig: savedBackupSchedule,
- lambdaStatus: lambdaBackupStatus,
- availableEnvironmentIds,
- });
+ const enabledCadences = readEnabledCadences(params);
+ const secretSummary = 'Secret saved.';
+ const connectSummary = config.savedUrl
+ ? `Connected to ${config.savedUrl}`
+ : 'Connected.';
+ const scheduleSummary = `Backups: ${enabledCadences
+ .map(getCadenceLabel)
+ .join(', ')}`;
return (
-
-
-
- Lambda setup
-
-
-
- Current URL: {' '}
-
- {activeDeploymentUrl || 'No lambda function connected.'}
-
-
-
-
-
- {pingIndicator.label}
-
-
-
- Status is based on the `/api/datocms/plugin-health` ping.
-
-
-
{
- setDeploymentUrlInput(newValue);
- clearConnectionErrorState();
- }}
- />
-
-
- {
- setLambdaAuthSecretInput(newValue);
- clearConnectionErrorState();
- }}
- />
-
-
-
-
-
- setIsDeployProviderMenuOpen((current) => !current)
- }
- disabled={isDeployProviderMenuDisabled}
- style={lambdaActionButtonStyle}
- aria-haspopup="menu"
- aria-expanded={isDeployProviderMenuOpen}
- aria-controls={
- isDeployProviderMenuOpen ? deployProviderMenuId : undefined
- }
- >
- Deploy lambda
-
-
- {isDeployProviderMenuOpen && (
-
- )}
-
-
-
- {disconnectButtonLabel}
-
-
-
- {connectButtonLabel}
-
-
-
-
- {connectionErrorSummary && (
-
-
- {connectionErrorSummary}
-
- {connectionErrorDetails.length > 0 && (
-
setShowConnectionDetails((current) => !current)}
- >
- {showConnectionDetails ? 'Hide details' : 'Show details'}
-
- )}
-
- )}
-
- {showConnectionDetails && connectionErrorDetails.length > 0 && (
-
- {connectionErrorDetails.map((detail) => (
-
{detail}
- ))}
-
- )}
-
-
-
Backup schedule
-
-
- The scheduler runs once a day. The number of backups depends on your
- selected schedule options.
-
-
-
- {BACKUP_CADENCES.map((cadence) => (
-
- setCadenceEnabled(cadence, newValue)}
- />
-
- ))}
-
-
-
-
-
- Backup overview
-
-
- {isLoadingBackupOverview && (
-
- Refreshing Lambda backup status...
-
- )}
-
- {backupOverviewError && (
-
- {backupOverviewError}
-
- )}
-
-
- {backupOverviewRows.map((row) => {
- const isBackupNowLoading = backupNowInFlightCadence === row.scope;
- const isBackupNowDisabled =
- !canTriggerBackupNow || backupNowInFlightCadence !== null;
-
- return (
-
-
-
-
- {getCadenceLabel(row.scope)}
-
-
-
- Last backup: {row.lastBackup}
-
-
- Next backup: {row.nextBackup}
-
-
- Environment: {' '}
- {row.environmentLinked ? (
-
- {row.environmentName}
-
- ) : (
- row.environmentName
- )}
-
- {row.environmentStatusNote && (
-
- Status: {row.environmentStatusNote}
-
- )}
-
-
- {
- void triggerBackupNowForCadence(row.scope);
- }}
- disabled={isBackupNowDisabled}
- >
- {isBackupNowLoading ? 'Backing up...' : 'Backup now'}
-
-
-
- );
- })}
-
-
-
-
+
+
toggleStep('secret')}
+ summary={secretSummary}
+ >
+
+
+
+
toggleStep('connect')}
+ summary={connectSummary}
+ >
+
+
+
+
toggleStep('schedule')}
+ summary={scheduleSummary}
+ >
+
+
+
+
+
+
);
From 37633e69c8145b48d4954ebe2dfd1603e61f0817 Mon Sep 17 00:00:00 2001
From: "Roger Tuan (DatoCMS)"
Date: Tue, 30 Jun 2026 20:07:42 -0700
Subject: [PATCH 3/5] fix(automatic-backups): address code-review findings in
config wizard
Fixes from the xhigh review of the wizard rewrite:
- persistPluginParameters now merges against a running accumulator ref, so
sequential mount-flow persists compose even without a CMA token (was
clobbering the schedule migration on token-less roles).
- Mount health-check success persists only the connection result, not the
stale first-render secret/URL snapshot (was reverting a secret saved during
the in-flight check).
- Mount schedule migration only runs for an actually-stored legacy schedule, so
a fresh install no longer auto-completes step 3 with unchosen cadences.
- saveAndTestConnection re-creates missing backup environments on connect when a
schedule is already saved (restores the old connect behavior for reconnects),
surfaces failures via a transient error box independent of persistence, and no
longer overwrites a previously-healthy deployment URL on a failed test.
- saveSecret clears any recorded connection state on a secret change (not only a
healthy one), so re-saving after a 401 doesn't show a stale error.
- saveDebug reverts the optimistic toggle and alerts on persist failure.
- deriveStepStatuses/buildStatusChecklist drop a dead always-true sub-condition.
- StepSection gives a collapsed current/error step an explicit open affordance.
- TSDoc added to the remaining internal helpers.
tsc + vitest (60) + vite build all green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/config/StepSection.tsx | 10 ++
.../src/config/deriveStepStatuses.ts | 9 +-
.../src/config/useBackupsConfig.ts | 115 +++++++++++++-----
3 files changed, 100 insertions(+), 34 deletions(-)
diff --git a/automatic-environment-backups/src/config/StepSection.tsx b/automatic-environment-backups/src/config/StepSection.tsx
index 7a3ce7ed..40ebee85 100644
--- a/automatic-environment-backups/src/config/StepSection.tsx
+++ b/automatic-environment-backups/src/config/StepSection.tsx
@@ -205,6 +205,16 @@ export const StepSection = ({
)}
+
+ {!isDisabled &&
+ !isExpanded &&
+ (status === 'current' || status === 'error') && (
+
+
+ {status === 'error' ? 'Review & fix' : 'Open this step'}
+
+
+ )}
);
};
diff --git a/automatic-environment-backups/src/config/deriveStepStatuses.ts b/automatic-environment-backups/src/config/deriveStepStatuses.ts
index bb98b8d2..e611e87d 100644
--- a/automatic-environment-backups/src/config/deriveStepStatuses.ts
+++ b/automatic-environment-backups/src/config/deriveStepStatuses.ts
@@ -35,8 +35,10 @@ export const deriveStepStatuses = (params: BackupsParameters): StepStatuses => {
const urlSet = readDeploymentUrl(params) !== '';
const hasFailedPing =
urlSet && !connected && readConnection(params)?.status === 'disconnected';
- const scheduleSet =
- hasStoredBackupSchedule(params) && readEnabledCadences(params).length > 0;
+ // A stored schedule always carries at least one cadence (the save handler
+ // rejects an empty set and the normalizer defaults to daily+weekly), so
+ // presence alone is the completion signal.
+ const scheduleSet = hasStoredBackupSchedule(params);
const secret: StepStatus = secretSet ? 'ok' : 'current';
@@ -95,8 +97,7 @@ export const buildStatusChecklist = (
const connected = isConnectionHealthy(params);
const urlSet = readDeploymentUrl(params) !== '';
const enabledCadences = readEnabledCadences(params);
- const scheduleSet =
- hasStoredBackupSchedule(params) && enabledCadences.length > 0;
+ const scheduleSet = hasStoredBackupSchedule(params);
const secretItem: ChecklistItem =
secret === ''
diff --git a/automatic-environment-backups/src/config/useBackupsConfig.ts b/automatic-environment-backups/src/config/useBackupsConfig.ts
index 9c03f3d0..7725e236 100644
--- a/automatic-environment-backups/src/config/useBackupsConfig.ts
+++ b/automatic-environment-backups/src/config/useBackupsConfig.ts
@@ -34,6 +34,7 @@ import { generateAuthSecret } from './generateAuthSecret';
import {
type BackupsParameters,
getProjectTimezone,
+ hasStoredBackupSchedule,
isConnectionHealthy,
readAuthSecret,
readConnection,
@@ -46,14 +47,17 @@ const MISSING_AUTH_SECRET_MESSAGE =
'Enter Lambda auth secret before calling lambda endpoints.';
const BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS = 1200;
+/** Extract a human-readable message from an unknown thrown value. */
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : 'Unknown error';
+/** Resolve after `ms` milliseconds (used to space out backup-now retries). */
const delay = (ms: number): Promise =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
+/** The plugin's id from ctx, or undefined when it is missing/blank. */
const getPluginIdFromCtx = (ctx: RenderConfigScreenCtx): string | undefined => {
const candidate = (ctx.plugin as { id?: unknown } | undefined)?.id;
return typeof candidate === 'string' && candidate.trim()
@@ -129,18 +133,27 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
const persistQueueRef = useRef>(Promise.resolve());
const hasRunMountCheckRef = useRef(false);
const isMountCheckUnmountedRef = useRef(false);
+ // Merge base for the persist queue. Accumulates every write so sequential
+ // persists compose, even without a CMA token to re-read authoritative params
+ // (the frozen mount closure would otherwise merge each write against the stale
+ // first-render params and silently drop the previous write).
+ const latestPersistedParamsRef = useRef>(
+ toPluginParameterRecord(params),
+ );
// Snapshot of the first-render params so the run-once mount effect always
// validates against the values present at load, regardless of later persists.
const initialMountRef = useRef<{
secret: string;
url: string;
+ hasStoredSchedule: boolean;
scheduleNormalization: ReturnType;
} | null>(null);
if (!initialMountRef.current) {
initialMountRef.current = {
secret: savedSecret,
url: savedUrl,
+ hasStoredSchedule: hasStoredBackupSchedule(params),
scheduleNormalization: normalizeBackupScheduleConfig({
value: params?.backupSchedule,
timezoneFallback: projectTimezone,
@@ -156,9 +169,9 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
const persistPluginParameters = useCallback(
async (updates: Record) => {
const persistTask = async () => {
- let latestParameters = toPluginParameterRecord(
- ctx.plugin.attributes.parameters,
- );
+ // Default merge base is the running accumulator so writes compose in
+ // order regardless of token availability.
+ let latestParameters = latestPersistedParamsRef.current;
const pluginId = getPluginIdFromCtx(ctx);
if (pluginId && ctx.currentUserAccessToken) {
@@ -169,18 +182,19 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
baseUrl: ctx.cmaBaseUrl,
});
const plugin = await client.plugins.find(pluginId);
+ // Authoritative read also picks up any external changes.
latestParameters = toPluginParameterRecord(plugin.parameters);
} catch (error) {
debugLogger.warn(
- 'Falling back to local plugin parameters because authoritative read failed',
+ 'Falling back to accumulated plugin parameters because authoritative read failed',
{ pluginId, error: getErrorMessage(error) },
);
}
}
- await ctx.updatePluginParameters(
- mergePluginParameterUpdates(latestParameters, updates),
- );
+ const merged = mergePluginParameterUpdates(latestParameters, updates);
+ latestPersistedParamsRef.current = merged;
+ await ctx.updatePluginParameters(merged);
};
const queuedPersist = persistQueueRef.current.then(
@@ -469,13 +483,12 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
setUrlInput(verificationResult.normalizedBaseUrl);
setConnectionTestError(null);
+ // Persist ONLY the connection result. The secret and URL are already
+ // saved; re-writing them from the frozen first-render snapshot would
+ // clobber a secret the user saves while this check is in flight.
await persistPluginParameters({
- deploymentURL: verificationResult.normalizedBaseUrl,
- netlifyURL: verificationResult.normalizedBaseUrl,
- vercelURL: verificationResult.normalizedBaseUrl,
lambdaConnection: connectedState,
connectionValidationMode: 'health',
- lambdaAuthSecret: secret,
});
} catch (error) {
if (isCancelled()) {
@@ -514,7 +527,13 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
return;
}
- if (snapshot.scheduleNormalization.requiresMigration) {
+ // Only migrate a schedule that was actually stored in a legacy shape.
+ // A fresh install has no stored schedule, and persisting a default here
+ // would make step 3 auto-complete with cadences the user never chose.
+ if (
+ snapshot.hasStoredSchedule &&
+ snapshot.scheduleNormalization.requiresMigration
+ ) {
try {
await persistPluginParameters({
backupSchedule: snapshot.scheduleNormalization.config,
@@ -632,12 +651,12 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
try {
const previousSecret = readAuthSecret(params);
const secretChanged = nextSecret !== previousSecret;
- const wasConnected = isConnectionHealthy(params);
const updates: Record = { lambdaAuthSecret: nextSecret };
- // Changing a secret invalidates any prior healthy connection: re-gate the
- // Connect step so the user re-tests against the new secret.
- if (secretChanged && wasConnected) {
+ // Any secret change invalidates the recorded connection state (healthy or
+ // failed): clear it so the Connect step re-gates and the user re-tests
+ // against the new secret, instead of showing a stale error/OK.
+ if (secretChanged) {
updates.lambdaConnection = null;
updates.connectionValidationMode = null;
}
@@ -674,6 +693,7 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
return;
}
+ const hadHealthyConnection = isConnectionHealthy(params);
setIsConnecting(true);
setConnectionTestError(null);
@@ -692,14 +712,16 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
);
// Persist the deployment URL triplet (legacy netlify/vercel keys kept in
- // lockstep) together with the resulting connection state.
+ // lockstep) together with the resulting connection state. The secret is
+ // not re-written here — it is already saved, and re-persisting a value
+ // captured before this multi-second request risks clobbering a concurrent
+ // secret save.
await persistPluginParameters({
deploymentURL: verificationResult.normalizedBaseUrl,
netlifyURL: verificationResult.normalizedBaseUrl,
vercelURL: verificationResult.normalizedBaseUrl,
lambdaConnection: connectedState,
connectionValidationMode: 'health',
- lambdaAuthSecret: secret,
});
setUrlInput(verificationResult.normalizedBaseUrl);
@@ -707,6 +729,18 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
endpoint: verificationResult.endpoint,
});
ctx.notice('Lambda function connected successfully.');
+
+ // If a schedule is already saved (e.g. reconnecting to a fresh
+ // deployment), create any missing backup environments now — matching the
+ // old connect behavior. Fresh installs have no stored schedule yet, so
+ // creation stays step 3's responsibility.
+ if (hasStoredBackupSchedule(params)) {
+ await ensureBackupsExistForCadences({
+ baseUrl: verificationResult.normalizedBaseUrl,
+ lambdaAuthSecret: secret,
+ cadences: readEnabledCadences(params, projectTimezone),
+ });
+ }
} catch (error) {
if (error instanceof LambdaHealthCheckError) {
const disconnectedState = buildDisconnectedLambdaConnectionState(
@@ -715,16 +749,26 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
'config_connect',
);
debugLogger.warn('Lambda health check failed during connect', error);
- try {
- await persistPluginParameters({
- deploymentURL: candidateUrl,
- netlifyURL: candidateUrl,
- vercelURL: candidateUrl,
- lambdaConnection: disconnectedState,
- connectionValidationMode: null,
- });
- } catch {
- // Ignore persistence errors while surfacing the error in the UI.
+ // Always surface the failure in the UI, independent of persistence.
+ setConnectionTestError({
+ summary: error.message || 'Connection test failed.',
+ details: getLambdaConnectionErrorDetails(disconnectedState),
+ });
+ // Never clobber a previously-healthy deployment on a failed test (e.g.
+ // a typo). Only persist the failing URL/state when there is no working
+ // connection to preserve, so the error stays sticky during setup.
+ if (!hadHealthyConnection) {
+ try {
+ await persistPluginParameters({
+ deploymentURL: candidateUrl,
+ netlifyURL: candidateUrl,
+ vercelURL: candidateUrl,
+ lambdaConnection: disconnectedState,
+ connectionValidationMode: null,
+ });
+ } catch {
+ // Error already surfaced via connectionTestError above.
+ }
}
} else {
debugLogger.error('Unexpected error while connecting lambda', error);
@@ -736,7 +780,15 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
} finally {
setIsConnecting(false);
}
- }, [ctx, debugLogger, params, persistPluginParameters, urlInput]);
+ }, [
+ ctx,
+ debugLogger,
+ ensureBackupsExistForCadences,
+ params,
+ persistPluginParameters,
+ projectTimezone,
+ urlInput,
+ ]);
const disconnect = useCallback(async () => {
setIsDisconnecting(true);
@@ -857,10 +909,13 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
try {
await persistPluginParameters({ debug: enabled });
} catch (error) {
+ // Revert the optimistic toggle and tell the user it did not stick.
+ setDebugEnabled(!enabled);
debugLogger.error('Could not persist debug setting', error);
+ await ctx.alert('Could not save the debug setting.');
}
},
- [debugLogger, persistPluginParameters],
+ [ctx, debugLogger, persistPluginParameters],
);
const backupNow = useCallback(
From 4835f9e41588acb0a644164454c98adb4e52b7e0 Mon Sep 17 00:00:00 2001
From: "Roger Tuan (DatoCMS)"
Date: Tue, 30 Jun 2026 20:22:37 -0700
Subject: [PATCH 4/5] feat(automatic-backups): config wizard UI pass
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 1 (auth secret):
- Regenerate is now an icon button beside the field (tooltip via native title
+ aria-label); Save and Copy are merged into one "Save and copy" primary
button with an inline Spinner while saving.
- The edit warning always offers a "Revert to saved?" link (new revertSecret),
with the redeploy note shown when a healthy connection exists.
- The deploy box no longer repeats the secret value and now reads: set
DATOCMS_BACKUPS_SHARED_SECRET to the secret above, then continue with the
deployed URL.
- New hook handlers: saveAndCopySecret, revertSecret.
Accordion / feedback:
- Replaced the single expandedStep with a multi-open Set so [Edit] is additive
and never collapses a step above the clicked one — fixes the scroll-jump CLS.
Kept the custom StepSection (numbered card) over the SDK collapsible Section.
- Added SDK Spinners with explicit "what we're waiting for" text to the Connect,
Schedule, and Status-overview inline boxes (and per-cadence backup-now rows).
New StepTimeline: horizontal progress stepper at the top with done/current/error
/upcoming nodes, connectors, and an "All ok!" terminal state.
Advanced settings: replaced the opaque "runs in Lambda cron mode only" note with
a plain-language explanation of the scheduled-function backup flow (daily 02:05
UTC, clones the primary environment into sandbox backups).
tsc + vitest (60) + vite build all green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/config/AdvancedSettings.tsx | 5 +-
.../src/config/StatusOverview.tsx | 24 ++-
.../src/config/StepConnect.tsx | 15 +-
.../src/config/StepSchedule.tsx | 17 +-
.../src/config/StepSecret.tsx | 144 ++++++++------
.../src/config/StepTimeline.tsx | 176 ++++++++++++++++++
.../src/config/useBackupsConfig.ts | 18 ++
.../src/entrypoints/ConfigScreen.tsx | 40 ++--
8 files changed, 351 insertions(+), 88 deletions(-)
create mode 100644 automatic-environment-backups/src/config/StepTimeline.tsx
diff --git a/automatic-environment-backups/src/config/AdvancedSettings.tsx b/automatic-environment-backups/src/config/AdvancedSettings.tsx
index 8d0c115e..b8e97782 100644
--- a/automatic-environment-backups/src/config/AdvancedSettings.tsx
+++ b/automatic-environment-backups/src/config/AdvancedSettings.tsx
@@ -45,7 +45,10 @@ export const AdvancedSettings = ({
fontSize: 'var(--font-size-xs)',
}}
>
- This plugin runs in Lambda cron mode only.
+ Backups are created by the scheduled serverless function you deploy:
+ it runs once a day (02:05 UTC) and clones your primary environment
+ into sandbox backups. This plugin only configures and monitors that
+ function — nothing runs from your browser.
diff --git a/automatic-environment-backups/src/config/StatusOverview.tsx b/automatic-environment-backups/src/config/StatusOverview.tsx
index e1158e28..1d2fbb5e 100644
--- a/automatic-environment-backups/src/config/StatusOverview.tsx
+++ b/automatic-environment-backups/src/config/StatusOverview.tsx
@@ -1,4 +1,4 @@
-import { Button } from 'datocms-react-ui';
+import { Button, Spinner } from 'datocms-react-ui';
import type { CSSProperties } from 'react';
import type { BackupOverviewRow } from '../types/types';
import { getCadenceLabel, normalizeBackupScheduleConfig } from '../utils/backupSchedule';
@@ -141,15 +141,18 @@ export const StatusOverview = ({
{isLoadingOverview && (
-
- Refreshing backup status…
-
+
+
+
+ Loading backup status…
+
+
)}
{overviewError && (
@@ -212,6 +215,7 @@ export const StatusOverview = ({
void backupNow(row.scope);
}}
disabled={isRowDisabled}
+ leftIcon={isRowLoading ? : undefined}
>
{isRowLoading ? 'Backing up…' : 'Backup now'}
diff --git a/automatic-environment-backups/src/config/StepConnect.tsx b/automatic-environment-backups/src/config/StepConnect.tsx
index 1acdf5d5..f8c8c130 100644
--- a/automatic-environment-backups/src/config/StepConnect.tsx
+++ b/automatic-environment-backups/src/config/StepConnect.tsx
@@ -1,8 +1,14 @@
-import { Button, TextField } from 'datocms-react-ui';
+import { Button, Spinner, TextField } from 'datocms-react-ui';
import { useState } from 'react';
import { StatusBox } from './StatusBox';
import type { BackupsConfig } from './useBackupsConfig';
+const spinnerRowStyle = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 'var(--spacing-s)',
+} as const;
+
/**
* Step 2 — point the plugin at the deployed function and verify it responds and
* authenticates. The single action persists the URL triplet, runs the health
@@ -72,7 +78,12 @@ export const StepConnect = ({ config }: { config: BackupsConfig }) => {
{isTesting ? (
- Testing connection…
+
+
+
+ Testing connection to {urlInput.trim() || 'your function'}…
+
+
) : connectionTestError ? (
{connectionTestError.details.length > 0 && (
diff --git a/automatic-environment-backups/src/config/StepSchedule.tsx b/automatic-environment-backups/src/config/StepSchedule.tsx
index fd028b39..216f8dc9 100644
--- a/automatic-environment-backups/src/config/StepSchedule.tsx
+++ b/automatic-environment-backups/src/config/StepSchedule.tsx
@@ -1,4 +1,4 @@
-import { Button, SwitchField } from 'datocms-react-ui';
+import { Button, Spinner, SwitchField } from 'datocms-react-ui';
import type { CSSProperties } from 'react';
import { BACKUP_CADENCES, getCadenceLabel } from '../utils/backupSchedule';
import { StatusBox } from './StatusBox';
@@ -53,8 +53,19 @@ export const StepSchedule = ({ config }: { config: BackupsConfig }) => {
- {progressMessage && (
- {progressMessage}
+ {isSavingSchedule && (
+
+
+
+ {progressMessage ?? 'Saving schedule and creating initial backups…'}
+
+
)}
>
);
diff --git a/automatic-environment-backups/src/config/StepSecret.tsx b/automatic-environment-backups/src/config/StepSecret.tsx
index baf0b15c..646ac7d3 100644
--- a/automatic-environment-backups/src/config/StepSecret.tsx
+++ b/automatic-environment-backups/src/config/StepSecret.tsx
@@ -1,4 +1,4 @@
-import { Button, TextField } from 'datocms-react-ui';
+import { Button, Spinner, TextField } from 'datocms-react-ui';
import {
type CSSProperties,
type KeyboardEvent,
@@ -15,6 +15,37 @@ import type { BackupsConfig } from './useBackupsConfig';
const DEPLOY_MENU_ID = 'deploy-provider-menu';
+/** Two-circular-arrows "regenerate" glyph (icons aren't shippable from the SDK). */
+const RegenerateIcon = () => (
+
+
+
+
+
+);
+
+const linkButtonStyle: CSSProperties = {
+ border: 0,
+ background: 'transparent',
+ padding: 0,
+ margin: 0,
+ color: 'inherit',
+ font: 'inherit',
+ fontWeight: 600,
+ textDecoration: 'underline',
+ cursor: 'pointer',
+};
+
const menuContainerStyle: CSSProperties = {
position: 'relative',
flex: '1 1 0',
@@ -50,17 +81,18 @@ const menuOptionStyle: CSSProperties = {
/**
* Step 1 — create/rotate the shared auth secret and deploy the scheduler. The
- * secret is generated (fresh install) or loaded as-is, saved explicitly, then a
- * provider deploy menu + paste callout are revealed. Editing a saved secret
- * while connected raises a redeploy warning.
+ * field carries an inline regenerate icon; one "Save and copy" action persists
+ * the secret and puts it on the clipboard for the deployment env var. Editing a
+ * saved secret raises a warning (with a "Revert to saved?" escape hatch), and a
+ * provider deploy menu + paste callout are revealed once a secret is saved.
*/
export const StepSecret = ({ config }: { config: BackupsConfig }) => {
const {
secretInput,
setSecretInput,
- saveSecret,
+ saveAndCopySecret,
regenerateSecret,
- copySecret,
+ revertSecret,
isSavingSecret,
savedSecret,
isConnected,
@@ -72,7 +104,6 @@ export const StepSecret = ({ config }: { config: BackupsConfig }) => {
const hasSavedSecret = savedSecret.trim().length > 0;
const trimmedInput = secretInput.trim();
const secretEdited = hasSavedSecret && trimmedInput !== savedSecret.trim();
- const showRedeployWarning = isConnected && secretEdited;
useEffect(() => {
if (!isMenuOpen) {
@@ -113,76 +144,69 @@ export const StepSecret = ({ config }: { config: BackupsConfig }) => {
return (
<>
-
-
-
-
- Generate
-
-
{
- void copySecret();
- }}
- disabled={trimmedInput.length === 0}
- >
- Copy
-
+
+
+
+
+ {/* Native title on the wrapping span carries the tooltip — the icon-only
+ Button has no text label. */}
+
+
+
+
+
+
+
+
{
- void saveSecret();
+ void saveAndCopySecret();
}}
disabled={isSavingSecret || trimmedInput.length === 0}
+ leftIcon={isSavingSecret ? : undefined}
>
- {isSavingSecret ? 'Saving…' : 'Save secret'}
+ {isSavingSecret ? 'Saving…' : 'Save and copy'}
- {showRedeployWarning && (
+ {secretEdited && (
- Changing this means updating DATOCMS_BACKUPS_SHARED_SECRET{' '}
- on your deployment and redeploying, or the connection will fail.
+ {isConnected ? (
+ <>
+ Changing this means updating{' '}
+ DATOCMS_BACKUPS_SHARED_SECRET on your deployment and
+ redeploying, or the connection will fail.{' '}
+ >
+ ) : (
+ <>You’ve modified the saved secret. >
+ )}
+
+ Revert to saved?
+
)}
{hasSavedSecret && (
<>
- Paste this value as DATOCMS_BACKUPS_SHARED_SECRET on your
- provider, then come back with the deployed URL.
-
- {savedSecret}
-
+ Deploy the scheduler to your provider, setting{' '}
+ DATOCMS_BACKUPS_SHARED_SECRET to the secret above. Once
+ you have the deployed URL, continue to the next step.
= {
+ ok: 'var(--color--success-soft--ink)',
+ current: 'var(--color--primary)',
+ error: 'var(--color--danger-soft--ink)',
+ disabled: 'var(--color--ink-subtle)',
+};
+
+/** Check glyph shown inside an `ok` node (icons aren't shippable from the SDK). */
+const CheckGlyph = () => (
+
+
+
+);
+
+/** Cross glyph shown inside an `error` node. */
+const CrossGlyph = () => (
+
+
+
+
+);
+
+type TimelineNode = {
+ key: string;
+ label: string;
+ status: StepStatus;
+ /** Number shown in a `disabled` setup node; the terminal node has none. */
+ number?: number;
+};
+
+const CIRCLE_SIZE = 32;
+
+const NodeCircle = ({ status, number }: { status: StepStatus; number?: number }) => {
+ const color = NODE_COLOR[status];
+ const isFilled = status === 'ok' || status === 'error';
+
+ const circleStyle: CSSProperties = {
+ boxSizing: 'border-box',
+ width: `${CIRCLE_SIZE}px`,
+ height: `${CIRCLE_SIZE}px`,
+ borderRadius: '999px',
+ border: `2px solid ${color}`,
+ background: isFilled ? color : 'transparent',
+ color: isFilled ? '#fff' : color,
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: 'var(--font-size-s)',
+ fontWeight: 600,
+ lineHeight: 1,
+ flex: '0 0 auto',
+ };
+
+ return (
+
+ {status === 'ok' && }
+ {status === 'error' && }
+ {status === 'current' && (
+
+ )}
+ {status === 'disabled' && number}
+
+ );
+};
+
+/**
+ * Horizontal progress stepper rendered above the setup accordion. Derives its
+ * three setup nodes ("Secret", "Connect", "Cadence") plus a terminal node from
+ * the already-computed {@link StepStatuses}. Node visuals encode status (ok
+ * check / current dot / error cross / disabled number) and the connector between
+ * two nodes turns green once the left node is `ok`.
+ */
+export const StepTimeline = ({ statuses }: { statuses: StepStatuses }) => {
+ const isAllOk = statuses.currentStep === null;
+
+ const nodes: TimelineNode[] = [
+ { key: 'secret', label: 'Secret', status: statuses.secret, number: 1 },
+ { key: 'connect', label: 'Connect', status: statuses.connect, number: 2 },
+ { key: 'schedule', label: 'Cadence', status: statuses.schedule, number: 3 },
+ {
+ key: 'done',
+ label: isAllOk ? 'All ok!' : 'Done',
+ status: isAllOk ? 'ok' : 'disabled',
+ },
+ ];
+
+ return (
+
+ {nodes.map((node, index) => (
+
+
+
+
+ {node.label}
+
+
+
+ {index < nodes.length - 1 && (
+
+ )}
+
+ ))}
+
+ );
+};
diff --git a/automatic-environment-backups/src/config/useBackupsConfig.ts b/automatic-environment-backups/src/config/useBackupsConfig.ts
index 7725e236..526cdb9a 100644
--- a/automatic-environment-backups/src/config/useBackupsConfig.ts
+++ b/automatic-environment-backups/src/config/useBackupsConfig.ts
@@ -672,6 +672,22 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
}
}, [ctx, debugLogger, params, persistPluginParameters, secretInput]);
+ /**
+ * Persist the edited secret and immediately copy it to the clipboard so the
+ * user can paste it into their deployment's `DATOCMS_BACKUPS_SHARED_SECRET`
+ * env var in one action. Reuses {@link saveSecret} (validates + persists) and
+ * {@link copySecret} (clipboard + notice/alert).
+ */
+ const saveAndCopySecret = useCallback(async () => {
+ await saveSecret();
+ await copySecret();
+ }, [copySecret, saveSecret]);
+
+ /** Discard the in-flight secret edit, restoring the field to the saved value. */
+ const revertSecret = useCallback(() => {
+ setSecretInput(savedSecret);
+ }, [savedSecret]);
+
const saveAndTestConnection = useCallback(async () => {
const candidateUrl = urlInput.trim();
if (!candidateUrl) {
@@ -1010,7 +1026,9 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => {
debugEnabled,
// handlers
saveSecret,
+ saveAndCopySecret,
regenerateSecret,
+ revertSecret,
copySecret,
saveAndTestConnection,
disconnect,
diff --git a/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx b/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx
index be20c675..cb59b961 100644
--- a/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx
+++ b/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx
@@ -12,6 +12,7 @@ import { StepConnect } from '../config/StepConnect';
import { StepSchedule } from '../config/StepSchedule';
import { StepSection } from '../config/StepSection';
import { StepSecret } from '../config/StepSecret';
+import { StepTimeline } from '../config/StepTimeline';
import { useBackupsConfig } from '../config/useBackupsConfig';
import { getCadenceLabel } from '../utils/backupSchedule';
@@ -28,11 +29,14 @@ export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
const statuses = deriveStepStatuses(params);
const { currentStep } = statuses;
- // The current/error step auto-expands; users can [Edit] an `ok` step to
- // override. Syncing to `currentStep` (tracked via a ref) follows the wizard
- // as saves advance it, without clobbering manual edits.
- const [expandedStep, setExpandedStep] = useState
(
- currentStep,
+ // Multi-open accordion: track an open-step Set instead of a single expanded
+ // step. Toggling one step is purely additive and never collapses another, so
+ // clicking [Edit] on a lower step can't shift a higher one out from under the
+ // viewport (the old single-`expandedStep` model caused that scroll-jump CLS).
+ // We deliberately keep the custom StepSection (numbered card + status badge)
+ // rather than the SDK collapsible Section; the multi-open model is the fix.
+ const [openSteps, setOpenSteps] = useState>(
+ () => new Set(currentStep ? [currentStep] : []),
);
const previousCurrentStepRef = useRef(currentStep);
useEffect(() => {
@@ -43,19 +47,29 @@ export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
previousCurrentStepRef.current = currentStep;
// Right after the secret is saved (secret → connect) with nothing deployed
- // yet, keep step 1 expanded so its deploy menu + paste callout stay visible;
- // the user advances to step 2 when they have a deployed URL in hand.
+ // yet, keep step 1's deploy callout visible AND reveal step 2 so the user
+ // can paste their deployed URL without losing the deploy menu.
const justSavedSecretBeforeDeploy =
previous === 'secret' && currentStep === 'connect' && !config.savedUrl;
if (justSavedSecretBeforeDeploy) {
+ setOpenSteps(new Set(['secret', 'connect']));
return;
}
- setExpandedStep(currentStep);
+ // On any other current-step change, focus the accordion on the new step.
+ setOpenSteps(new Set(currentStep ? [currentStep] : []));
}, [currentStep, config.savedUrl]);
const toggleStep = (step: SetupStepId) =>
- setExpandedStep((current) => (current === step ? null : step));
+ setOpenSteps((prev) => {
+ const next = new Set(prev);
+ if (next.has(step)) {
+ next.delete(step);
+ } else {
+ next.add(step);
+ }
+ return next;
+ });
const enabledCadences = readEnabledCadences(params);
const secretSummary = 'Secret saved.';
@@ -69,12 +83,14 @@ export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
return (
+
+
toggleStep('secret')}
summary={secretSummary}
>
@@ -86,7 +102,7 @@ export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
title="Connect & test"
description="Tell the plugin where your function is deployed and verify it responds and authenticates."
status={statuses.connect}
- isExpanded={expandedStep === 'connect'}
+ isExpanded={openSteps.has('connect')}
onToggle={() => toggleStep('connect')}
summary={connectSummary}
>
@@ -98,7 +114,7 @@ export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
title="Backup cadence"
description="Choose how often backups run. The scheduler runs once daily and creates the sandbox backups you enable."
status={statuses.schedule}
- isExpanded={expandedStep === 'schedule'}
+ isExpanded={openSteps.has('schedule')}
onToggle={() => toggleStep('schedule')}
summary={scheduleSummary}
>
From eb47cd0a3348a363e4656b92c00fc486ae979c9d Mon Sep 17 00:00:00 2001
From: "Roger Tuan (DatoCMS)"
Date: Tue, 30 Jun 2026 20:28:54 -0700
Subject: [PATCH 5/5] docs(automatic-backups): update README/CHANGELOG/AGENTS,
bump to 0.7.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- README: rewrite Setup for the four-step wizard, refresh "How it works" and
"Managing the connection", embed the new config-screen screenshot.
- Add a plugin-local AGENTS.md documenting the src/config architecture (params
as single source of truth, per-step saves, pure tested modules, the mount
run-once/StrictMode and multi-open-accordion gotchas).
- CHANGELOG: 0.7.0 entry.
- Bump version 0.6.13 → 0.7.0 (minor).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
automatic-environment-backups/AGENTS.md | 49 ++++++++++++++++++
automatic-environment-backups/CHANGELOG.md | 1 +
automatic-environment-backups/README.md | 40 +++++++-------
.../docs/config-wizard.png | Bin 0 -> 299370 bytes
.../package-lock.json | 12 ++++-
automatic-environment-backups/package.json | 2 +-
6 files changed, 81 insertions(+), 23 deletions(-)
create mode 100644 automatic-environment-backups/AGENTS.md
create mode 100644 automatic-environment-backups/docs/config-wizard.png
diff --git a/automatic-environment-backups/AGENTS.md b/automatic-environment-backups/AGENTS.md
new file mode 100644
index 00000000..1735fc2d
--- /dev/null
+++ b/automatic-environment-backups/AGENTS.md
@@ -0,0 +1,49 @@
+# Automatic Environment Backups — agent notes
+
+The configuration screen is a gated four-step wizard. All of its UI and state
+live under `src/config/`; `src/entrypoints/ConfigScreen.tsx` is a thin
+orchestrator.
+
+## Architecture
+- **Single source of truth:** the persisted plugin parameters
+ (`ctx.plugin.attributes.parameters`), read via the pure getters in
+ `src/config/pluginParams.ts`. Never keep a parallel React snapshot of saved
+ values — that desync is what made the plugin send the wrong auth token.
+- **Per-step saves:** every user change is committed by an explicit step action
+ (`useBackupsConfig` handlers: `saveSecret` / `saveAndCopySecret`,
+ `saveAndTestConnection`, `saveSchedule`, `saveDebug`). There is no global Save
+ button. A save+act handler acts on the value it just persisted, never on
+ unsaved input.
+- **Pure, unit-tested modules** (vitest, no DOM harness): `generateAuthSecret.ts`
+ (128-bit hex secret), `pluginParams.ts` (typed param getters), and
+ `deriveStepStatuses.ts` (step gating + the Status-overview checklist). Put new
+ logic behind pure functions and test it here; there is intentionally no
+ component/render test harness.
+- **Orchestration hook** `useBackupsConfig.ts`: edit-state, a queued
+ authoritative-merge persister, the run-once mount health ping, overview/env
+ loaders, and `ensureBackupsExistForCadences` (sequential creation with a 409
+ retry).
+- **Components:** `StepSection` (numbered-card chrome), `StepSecret` /
+ `StepConnect` / `StepSchedule`, `StatusOverview`, `StatusBox`,
+ `AdvancedSettings`, and `StepTimeline` (top progress stepper).
+
+## Gotchas
+- DatoCMS `ctx` gets a **new identity after every `updatePluginParameters`**, and
+ the app wraps in `` (dev double-invoke). The mount health ping must
+ run **once** via the `hasRunMountCheckRef` / `isMountCheckUnmountedRef` guard
+ with an empty-deps effect. An effect that persists params *and* depends on
+ ctx-derived callbacks will re-fire forever — that was a real infinite-loop bug.
+- The accordion is **multi-open** (an open-step `Set`) on purpose: single-open
+ collapsed a section above the clicked one and caused a scroll-jump (CLS).
+- The **persisted-parameter schema must stay backward-compatible**:
+ `deploymentURL` / `netlifyURL` / `vercelURL`, `lambdaConnection`,
+ `connectionValidationMode`, `lambdaAuthSecret`, `backupSchedule`, `debug`.
+- The actual backup work runs in an **external scheduled function** (repo:
+ `marcelofinamorvieira/datocms-backups-scheduled-function`), `executionMode:
+ 'lambda_cron'`, daily at 02:05 UTC. This plugin only configures and monitors
+ it — nothing runs from the browser.
+
+## Validate
+- `npm run test` (tsc + vitest) and `npm run build`. No lint script is wired
+ locally; the repo-root `biome.json` is the formatting baseline.
+- Design spec: `docs/superpowers/specs/2026-06-30-config-wizard-design.md`.
diff --git a/automatic-environment-backups/CHANGELOG.md b/automatic-environment-backups/CHANGELOG.md
index 4c1c4ab8..8f53998c 100644
--- a/automatic-environment-backups/CHANGELOG.md
+++ b/automatic-environment-backups/CHANGELOG.md
@@ -1,5 +1,6 @@
# Changelog
+- 0.7.0: Redesigned the configuration screen as a guided four-step wizard (auth secret & deploy → connect & test → backup cadence → status overview) with a top progress bar and per-step status/loading feedback. Saved plugin parameters are now the single source of truth, fixing an auth-secret desync that could make the plugin send the wrong/default token (HTTP 401). Fixed an infinite request loop on the config screen. Fresh installs auto-generate a strong shared secret, and editing it offers a "Revert to saved?" undo. Existing setups keep working with no migration.
- 0.6.13: Fixed a crash when opening the Deploy lambda menu.
- 0.6.8: Added the README preview image, moved the changelog into this file, and updated the plugin metadata to use the JPEG preview.
- 0.6.7: README update.
diff --git a/automatic-environment-backups/README.md b/automatic-environment-backups/README.md
index 94dbf5d4..cc0aa6ed 100644
--- a/automatic-environment-backups/README.md
+++ b/automatic-environment-backups/README.md
@@ -6,42 +6,42 @@ Because DatoCMS does not have a built-in job scheduler, the plugin has to create
The lambda function is only used as a job scheduler, similar to a cronjob. The lambda calls the DatoCMS Content Management API (CMA) to actually manage the environments and perform the backups.
+
+
## How it works
+- The plugin's configuration screen is a guided, four-step wizard with a progress bar showing the state of each step.
- Backup cadence is configured in the plugin (`daily`, `weekly`, `bi-weekly`, `monthly`).
-- The deployed scheduler calls the backup endpoints.
-- The plugin validates connectivity between the serverless functions using health checks against `/api/datocms/plugin-health`.
-- The **Backup overview** lists each enabled cadence with its last run, next scheduled run, and the linked sandbox environment, plus a per-slot **Backup now** button for on-demand execution.
-- The created backups are just forked sandboxes inside your DatoCMS project (named with a `backup-plugin-` prefix), NOT separate files on an external provider. The external providers are only used to provide scheduled lambda executions that call our API to create a scheduled backup.
-- Source code for the deployed lambda function is at https://github.com/marcelofinamorvieira/datocms-backups-scheduled-function#deploying-on-cloudflare-workers (this lambda was written by Marcelo Finamor, a DatoCMS employee).
+- The deployed scheduler runs once a day (02:05 UTC) and calls the backup endpoints.
+- The plugin validates connectivity to the serverless function with a health check against `/api/datocms/plugin-health`, authenticated with a shared secret.
+- The **Status overview** lists each enabled cadence with its last run, next scheduled run, and the linked sandbox environment, plus a per-cadence **Backup now** button for on-demand execution.
+- The created backups are just forked sandboxes inside your DatoCMS project (named with a `backup-plugin-` prefix), NOT separate files on an external provider. The external providers are only used to run the scheduled function that calls the CMA to create a backup.
+- Source code for the deployed lambda function is at https://github.com/marcelofinamorvieira/datocms-backups-scheduled-function (this lambda was written by Marcelo Finamor, a DatoCMS employee).
## Before you begin
+
- You will need an account with Vercel, Netlify, or Cloudflare that is capable of creating projects and adding serverless functions (lambdas). Usually the free plan will suffice.
- In your DatoCMS project, you will have to create a new API token with access to the CMA and an admin role. In older DatoCMS projects, this may have been automatically created as a "Full Access API Token", but newer projects will require manual creation of a similar token.
## Setup
-1. Make sure you've read the "Before you begin" section, above.
-2. Install the plugin.
-3. Open your DatoCMS project Configuration and find the Automatic Environment Backups plugin settings.
-4. In the **Lambda setup** section, leave the Lambda URL blank for now (it will be used later).
-5. Change the default `superSecretToken` lambda auth secret to something safer, preferably a pseudorandom string.
-6. Click the **Deploy lambda** button and choose one of the provided options (Vercel, Netlify, or Cloudflare).
-7. A new browser tab will open where you must finish the lambda setup on that provider. You'll have to provide the project CMA API token (`DATOCMS_FULLACCESS_API_TOKEN`) and the lambda auth secret (`DATOCMS_BACKUPS_SHARED_SECRET`) that you configured earlier.
-8. Once the lambda is deployed on the external provider, find and copy its deployment domain, e.g. `https://my-backup-app.vercel.app/` (just the domain, no path needed). Make sure it is publicly accessible and not hidden behind a preview login gate.
-9. Back in the plugin settings, paste that deployed URL into the **Lambda URL** field.
-10. Click **Connect**, wait a few seconds, and confirm that the health check status is **Connected (ping successful)**.
-11. Toggle on the backup cadences you want under **Backup schedule** and click **Save**. After saving, the plugin automatically creates any missing backup environments for the enabled cadences.
+The configuration screen walks you through four steps. Completed steps collapse to a summary you can re-open with **Edit**; the progress bar at the top shows overall state and reads **All ok!** once everything is configured.
+
+1. Read the "Before you begin" section above, then install the plugin and open your DatoCMS project Configuration → Plugins → Automatic Environment Backups.
+2. **Step 1 — Auth secret & deploy.** A strong shared secret is generated for you (use the regenerate icon to roll a new one). Click **Save and copy** to store it and copy it to your clipboard. Then click **Deploy to…** and pick a provider. On the provider, set the `DATOCMS_BACKUPS_SHARED_SECRET` environment variable to the secret you just copied, and `DATOCMS_FULLACCESS_API_TOKEN` to your CMA/admin token. Deploy, then copy the deployment's public URL (e.g. `https://my-backups.netlify.app`).
+3. **Step 2 — Connect & test.** Paste the deployed URL into **Deployed function URL** and click **Save & test connection**. The status box confirms the function responds and authenticates, or shows the exact error (for example, an auth mismatch means the plugin's secret and the provider's `DATOCMS_BACKUPS_SHARED_SECRET` differ — make them match and redeploy).
+4. **Step 3 — Backup cadence.** Toggle the cadences you want and click **Save & continue**. The plugin creates any missing backup environments for the enabled cadences.
+5. **Status overview.** Once all three steps are green, the overview shows "Configured and ready", plus the last/next backup and linked environment for each cadence and a per-cadence **Backup now** button. You can leave the screen — backups run on their own.
## Managing the connection
-- Use **Change Lambda URL** to point at a different deployment; the plugin re-runs the health ping against the new URL before persisting it.
-- Use **Disconnect** to clear the saved Lambda URL. The cron schedule on the external provider keeps running until you remove the deployment there, but the plugin will no longer surface its status.
-- Re-opening the configuration screen runs a health check against the saved URL automatically, so a stale or expired deployment is caught before you make any other changes.
+- Re-open any completed step with its **Edit** button to change the secret, URL, or cadence. Each step re-validates and re-gates the later steps when needed — for example, changing the shared secret clears the connection so you re-test it (remember to update `DATOCMS_BACKUPS_SHARED_SECRET` on your deployment and redeploy).
+- Use **Disconnect** in step 2 to clear the saved deployment URL. The cron schedule on the external provider keeps running until you remove the deployment there, but the plugin will no longer surface its status.
+- Re-opening the configuration screen automatically re-runs a health check against the saved URL, so a broken or expired deployment is caught immediately and surfaced on the affected step (and in the Status overview).
## Advanced settings
-- **Enable debug logs** — When enabled, plugin events and outbound lambda requests are logged to the browser console for troubleshooting.
+- **Enable debug logs** — When enabled, plugin events and outbound requests are logged to the browser console for troubleshooting.
## Changelog
diff --git a/automatic-environment-backups/docs/config-wizard.png b/automatic-environment-backups/docs/config-wizard.png
new file mode 100644
index 0000000000000000000000000000000000000000..0120aa54418e5439909abce75dd55d327138050d
GIT binary patch
literal 299370
zcmeFZRa6{X)HO(vrUVi5s!3_(E!6nBQ-I@cAx
z7z%>PtO~h7*Tjm@xpm`GLXZ;TXD5ocZ>##C)~oIY
zS+>Na$Pn-TZj_?!zHsocqankOUwlc_Y>lu?WZ6lGWsX|4$;3Kgm?!RybnbL
z0r~It9$rA6^*0QzAOy6)KN}HcHqzf_AmBceLI_R}VOsq6dOtIN^Zln8qVJIq&?0{7
zl_-Ck5dZ^v{1**)=l=rw|5*>U(~(f_?(8TiDx#8+lFBzRf+lrTm6I7digV~VX0|4g+IC=4yTJq
zIJvlr$y0e;qY1oR(2a}^Odqd{9QyF<)ZC`Lo-9#Xu14Lb%XPFgE5Fk>)YmINa)#0|
zQcwhb69)l-ir$Y~Mk&YDm6b%exVSIInm|LUmYdn7=^8U7%^K4$4NXm0$6Z)|qnc8>
zJr|~@w3IfP^?-)U>lqq>C!Q902k?Q8wlp@99CB$+&szeY+esdBGf!%x^kk@*Ic#mV
zOpA(^Hc?SAnu_@nC;=C|=5dApax*7+IGzRda^AJ=3uv-l^CCm$;^v-;rHY|qtjt=h
zGw753@aa>2VxoHR@83T&)dfE4{X1-e(HPAH6>5HDGu7196{wbJQh&-e9qjBPF}*wG
ztpUt=Ur$O^jQ55e^)GVi<&bCCX{+S!Wq8LcDow?TRtfq&s@Q(W8EUrq68rjOO;Pt&
zJS+b}LQYQ2Lt95D1a4H0m+jZ)=4O%65GF0pdmK+bynn}^yeC{@O=e-+M`oCnCTHv9
zuNN#YYu-Q2r_UMY3*LZUFYjyHyj~u!+o6PKcF)gkG(i@ea#Na9
zen_iwUS3}2PX|*brX$I04zC`+sR8TlO$YN;29kw`Yi$f{)~l&k8uAeYD+41VGVnRf
zwNFddZ6!S5Yvv$e(T}gTJWVTGw|RLUjB>jlmDr6Gm|m-v-iOGhejQC#l#zj1^L~>r
zkFXY84GopntTo5L=kxdm@5QXc<8m%gtkVPnc4@ah%gykKiD3mtMC1&_6v(HrmkkUI
z1Yt7hX8T)6Nl7JhShp6xkwk>b8#y1%s?-YlpDr(J$8iu7M;p2u8j>QUH%WvaE#1-I
zsL6kMB|cfIIk3(PLl?XX=XQ%SWe|y>;Q`E+76A!)Tq6D3OkD%i#?pEBPn%#1?GO?I
z&;Fek$)$f#BHaXU|ymYvjcta&tv^NwX8%|5>XN<8iNMyJ%rr09sDh3uJb345z2eKmS<6g`6?H%Xo8_Wnbxq9-BqWX*J3C_Ynr3yay*`~G#wDCc&eS50T3P89ha)1=YggQ{VoD^bC;B~PnboH=Z}Gcjmc(zkgF?Vbu3)rrmN6cgt-gq+N#h}3_Wgo=
zBXd0q?5(Y>hkBIj+a+sTSGHJ|&}repgo8td*2He5vjg+R$Gwb#NpM(|-6IfgP))tk
zmH=5Mq;L7;a+wU$Gx51WcC8hOMg-Ow9mY@_ZXfUeU4R*FA&r@CXnp%T`~v5xw!|iD
zcs{^42zb9X_`)Pwn1ch{Zu`4T>O`T4d}vr$UbV^CRTWOl{pQv6BA}w!4u{>MU8#s<
z5W`%oV5+Fd5+$8fLqlV{ZcR2T1d9QsB$aif`c*%thV33+@Umbf7pspQf}gM
zFdeg;Hoe*%p&ncB@KZm>%>?m+JAzi-N?BDhSJV;bYfz^Wy7~pALY)>R555p
ziP2Hn>p_|d8XtxH-Mmv2$h00J2Rfx0sqo9FPBoz~OqyvAwa;aqE)>^Dy)z
z;?I^Yqr$(PTrWCyO`5zn>e`E6Le(%vUbHJw`j%L_#Pb
z3*h*(eb7dd_y(qn_z<(av|RZ=rPZBRZO(sD8jwn$&+~p;k?82?7!-CrnzeM;?pH|W
zyxQ|UW~MtnJRG+CQ6iAOh#{z$-?UTFTx6Oe#V4s}JrMP_A_Q-Csgr+}hldg@qEskn
zAyworfl_nRCzpI3U>B;<!{ziT3j&m~`mwQ&r!h4jc6WC_8rWoE=8+x#^i16i7d0tuGEC`1$W7F&X4vTD=~YNDOXiEM9EK
z7?^?AABXk!4m*V4hvaXCXhXI^kud+8L>K!DRm}jNGDpAJf)GxP*vk@@aQYvVTIZD0sYZDvzct>Gcx3I78aN#4y*^
z^Ad*AQ}<w!^s$d(S*%2@!l+`$mf63L<5i&wGfUX?#a>~U|9Nl`uiJrxAgWj0tPeksw?-R1Ut
z?;Q8bX|)-qv}5Fl&vKAxn4ABRQ$;byE7U*L9-}8DCM_-cjW1&sS4btXlND-eqay%C
zk}F=F`=SOTOWZ%M)$<7*+AH4Zu$x^rei_Xqj9!PUL`8=*15Pp970pN5UkcudPE!oU
zsXM0LdhMO$c`RF0&(r;U)fV-7O-M*}G9ShUcRsrp=PQHQtWDzD7c&K+cAyo%u7hgyYJ%-_QFe2!pkNWdBk5NV_oVTA>&hGi
z8_1@xTPo|2`aSQ?|3b&W$O}i2+d-p{Exwg%I%_bA1dgHiZhF#LMjD}zNhB@Ry$vl2
z!D4J{fr7}Dd3wk~BV9BQC^WbqtNPLn=?ZCi5|HF{l&f(1*<`VdUuo3J4J-CUOK)(BW^HsU*VV`}gCOdRhVq5jeK@jJ
z^PLeea~&NDJ*Mb^A1D03M14@Wz8tEcp}2t9
zRKQdFhOkto#j!K4NJSy=RGxweZtFgA82731Far
zYZsC43bcO@CUS$hfn6^c1@C__y+bY}1~UjHaJ0cN|HK}cIiUcP0m~Iche&@(1eI?P
zzWNwGB31ulA^Ls_O+=aegz@d=FSC#t3@0F8N?b{a`4`}D8kje*Z}TnW{|iMK1q?+6
z8negupNjM6&jz%gnWm>|$TzV6Lh+#{h7ha@ZqHW#iui@5+MvszFb1n(!}_;@{v~Kn)q9{}*xl@*jHSj^_8{zs2wW!Ol}xQ)u8Qo9Cg6
zV78m2aP^$ple=E#6?rK|QYfk9
zUN3HWT6HYT@F>SypgzHSq`S@G_iA2gZF(Z0)J`?wBiKdPFucaQ#qryHU<&
z2T2tgUu)K3EnIbf2CJ$aJ_q8SVu%5kmx-pv(nKGz7emw8`a^Jt_xKJF_NUqsqk}QV)qEMV}O_EatdkYE~vbF)(G#*L{
ztSXui;_6WeWi!TN<@=Y4C->ipx|rKI_5?en!1)*zw3*0ks>KnLO2%}PbFe;8dN$m_
zOO8r1MyY1w)G_-QFuhIO!Dbk>1s-9c2E?!drX3s1SRd9|9EhDN7P44y1(^~~K6t}F
z{w7u$qgjd@B&O@k_9uGS*>6HmKvB`?$;Lxw6eo)3z4Ye1CBQumVRG1UbqucjoNo7P
z_q}InYTd_lJY2!dVE9z2uaa$PAPW-}-ONQ4!Qs{Cl>lxXmU8Dn3~EIwKc4KyKDE
z`&ptYIbPr$_PWFbMN^X+ksayBwdDApvlJMz`);QHzNxvIv;$U`C(7uXVTl}f(dVa<
zyP}Cg`V^B^ZeJVDKBm#t3Dz(97t$`9MM*`R!G24Ypx`Bxou7+p&VMaL}9L*aK);E9V?Ik^(X*
zH!Q(3OulcmE|Me@xI9EA`4#=iecKZ8DK2}e^V3N$J951hBr3U~)VwvGcKxPx(Iq2h
z3SAg~g$!mX{@lc1wNhN&O6^k>40LgLd(ypMO-7FVyl&u0{d`!->&Wa@PmDhaN`Wui
zo-yWHz#5S}74#%9#!RPQdS^v@0{(68w0Ew&C8O5P=pnqcEEe`W+hicROL
z5viV3we8LUkt0QWq8Uds<8#PCTpPT;`$e>d+40(*{})^n$*c|X30%5ec_K(e*N*h(
zk!39*5f&m21Cvk)1@3G(VZ@R|7G5PTJhT<5&k0t-g*i=;g}YW&UvTrb%i@!g-07N=
zYWQ+n1qNo>0~ug!rxwqh^-+}l(_S$-bhzkQW@RiIM*0N`r?6oVLG9TV4C>)$j~@xx
z((r*!p)yp%WGctutsQ0Ds4ZbR85Cg93$~~sYBKLr@DPVz#IKwWs+Ux!{A6?)6iO)j
zPTQKZTu|;mLB
z8ys-j96p{rqC*H$BGGU``p&P~OV*}B5j)cbtNo}$(f0k4cDvETLh?KbHidXd^MEzJ
z8==j@-LokJIzIidA79J(_|yE@K06Udvf(gP2@4d{4``PY9-5IucWbevw%pQO5@E<>
z!v$?M4V#qR6LED9JqL|zC?1Pw9BtCBv4|7dZ#_HkWML9}-WSY{R~B9%ha+sw4x~FNBBXqQRGhL(i*21Na?D$#B3>^OpXe
z%_G?tEn;%EQsn(pWh~uZeI$CWDhc(Ac79czeuXzj3ND9>UZS+_Y)!OD)K6+Fdy@<2?i&ANvcw|={+jl
z_~seW{9iUyWECp@UOK5bUJ0TCy>rp-XjKv-q)Xa)g|_oHocgaMyeWO=uLXDa37<1p
z5gqsaPQB%o#$;;_Iz13=o_!~~_pi_y~xR*aoK
z%l#lpwEbF;pIBi!c2%b_d1Xm4mHD&k*uv4D+@^G|zvhap*Dc<@+wj}D<{tL8;eiK~
zrn6@OBa|RhAWU5$>WS{RfTht2B;LhMC{2$;z#{f57J{#n`J(=}YUllnc(mBauRrD(
zF;VY;Cn26e-G+?A{Svz~^p|1M45avb2(J(sOO@||-M*DMnyAQGr+!u;l;b%oY$s#G
z5(=Irq=JgNcE8v>V;1k1W%=>l0(f)Airx*S&$W1#Tro_y%<|T7*x~n~;LA`o`uk#K
z17y2#w*`|+Lq-XQKVGkVCCFS|;5#3z5f`E8`jm#fWF-RG*evZ<+r@ggzgE$`n|Bo=
z<)kYb#IRwvUTxW?$o`uj&m(E;OK&k&w#OISC{$FAU&CATjtqrZuag9`it#*Xl2Y#X
zx=D8Hd5V2cli_`>hu;LYOsT{1K0aX$_RG*;80i{*N|GfQUzbRBbZA!O8eonoCCuGh
z^c1^;<`dpipL!W650u0}4Ja)|O6v{R;PkLY5tK(KS4qBJOo(6QrOK4Tee}2!U+g&A
zQ!|57s9!*iRL{Ve(eL-YM4TO+
zF6xDN9RC6DS-<5qYb7?Zq_bNY`PJV^tUODiNlZyoRYd>WS2(5i_P*NB
z(>P$bp^3$`uFbDDV?!C5v(^mH4_h5-{i}mP
zdLE}_D9AfmG(ww;>l%iok^AOHYW4eoR%c_DFg@4|j_dwzVfJz601sR>L@RyRO6WN+3F^6k^Il@DLC931KXn}^BfA{xvcCX(m9Xn{Kc{U9$
zr_Y&AwKvUn&vN8V2lsB`}##dKU&;W6b~d1ANc
zL)a)wk=hBY3U&!+`m!D|ynjjcTtwBslSKA)VZ$LOU}d%ugiOkJDj
zA!neJ^y}UQ80;@AE*bZ=bLiZw6h%Bu<4f3ww^+wUQU(l7|Da3gg$bw4OGoAwY=?s^
z2UFm6kPBB2=o%W>ttZ<*sAb7n5F-q@7e2CIR!ArzwG^j9hhD_b-hWx>|CyGHG9#jj
z%ozMLLrLefqISWHz5H8Q<5xW+;L9C{d!L}CDMZ$@NOxcB={nmiP*2GVc?Sc0OPG30+xMcyX?7z&@>fZh
z?&_nhcPn*nexblB&L!>Kj2{=NdD&DP92%mVbFjYvay{NKw8FOuW}?EWM_!k$lzyS5
z8^A3|6c~ngYs;*_B_q9r*tXajm1Sy?X7ziE!qXS`qleWy22zA4n)RO72}g@zgQ+DH7aEu)fPN+1vT9k7;d@2x2lH-YBL=oa2ZSZ`eJhb)dI%nh+@3E1ghfmbcSJE
zJTRupliRydmM0&U4NuGIqW>Kv2GP3Vk)$&$Pfu_TAVG{xSzE;L6P4CuWlq>ZV9!L|
z0vJtEsp(_m98PgQ-E~|=sa(`KpxSa1N=CP+lvn`~EGuCwJpfoh;1_Os;0)W~<5t@-
zFKoRD9%@f{;``$ifkul#03=wrDk{9?c
z&~BO@chFJIg1S{{7t>8@c`y_5IO|uVE<8n{=_&Lo`_8C1AnXVEs+g(;
z!?|I}*!<_EwM5}4Ea*`3bubf=Zu(G^>@-z>rlN}aq=Ot+U<(h+AGEh(q_;=K{bjiDA1J2
z=04mF1k|rG+w#E0ie$>YpwQBKjcO)5XSN&dFaX-%f=)rNc&YAufL^Gf!SEL%`8;R>
zi$`kS@qI@lC3;(o3XTIimLmEkhp;2&1Yzj|;RGG)qOSzdlH}uqb+9eVKd1wnY~^1O
z$o6R02vATh>F^KY5c!o5&dPVV`~B@SJckGT50#8no;g1XXS&03q2C}eY@w?a2kMvw
z3^-JqqV|DaqYF~63})lv37-Tqnc-{B4|N$CQj1Bq7|swa
z(EDgwx-1m@Iz5SXzDm3Kb#-JtUQdfi6Qz%y$dmpU
z;On68syNYidT3H~absDWJA-uJEe`Fl)kA*%NM2l_i6a>iC=i&MAw3pkJQ1%p
zV^`y}(W_(&Z|6SEb%n!Fr3E7j75qm#mvK1ju
z>&`hu3|p{;WM?rBG(v@sLrZlDB~}zJFKXoMA!9WYWJ=-P#3aZNAG0_X7_&SSx3)Tv
zqA#KQio2{kZ
ze?U70ROA=TI%XRc6riVqbBX4v8$?a7M+!t8r+D4v=``^BwDWqCSSZwMYkaX@e5|eF
z@!O>?Zh;vn_TgU2hD}AUU;&q{T(?^?{xu@oCRyq)HrJHSXt_x&cLF6TVR-qdSv2Mx
z33~kvQXefePI8@M`l9dl?*nn7I;aw`O6HIhfi^988NE=Mq*QX7^PY8YB3#&Y8NwXO
ztG??n7##^($pRny&YunEY(8v!)_OVQ7cBEQ&4lTD6T9-|qcU|imi=|;d|4!SfM+b|
z6(T_4sYVE^#~O`#D$%(^3LfV~R{=2}zXmlQid-mrxPLRJ^mJZ#)gj|wvdE4e$Y^&x1&WuXLKF(AU|LIv_NrVv5Gx@yiu#-r9Yn=XR^SIJ%avo||50+i4&2
z$Z{@$dqZON3j4+3?ni#+p46!*TP%J^*X%YPDQa*~(jqqzu)RgSwnaxFkK3lpdUg+u
z7;Pq(aY7(r?VKc1H^($qi!IYQx6&EMjZ>YL5dSd?o;Q9H-;uC>a*{{4_xPt9%7U{i
zTANO*#Kb`so4xCa(VHharA0ntO#+0>yg0nV^W2#Dc9p&jvCjixD%egJ6BuFD;g+r%l)8GDopU&
zVyVJ(cjE$YK5;)Ir0w!n+pS=!RO_BP(=1MgNF_mf$<-)M_SLiDY*?k|&jSm+6Ibo9
zEP~tCVty;Qq%H#|0>=u@Slv@bkuqA=opz}j5)V#>y}dC{wwNulM*C7uEiRV_!pDno
z0FOJGpzdw$e$Q=8QQF!`sTp?X18~}th(Y%5>N8*C&eQXyqp#OGaFq0T%f+$0l*h%7
z!2A)1p~5LR1BStd*LPZ-ulV7KmC{Dqh(y43=BEIu913B?ir~v3?=S*kX+dUUnmLuE
zSz|x`rQDki-AlgL&RgBpq-3(K15Zz$-X>c%?zsI)TNXQCU5SB3eVf?T?Zwh*)?>5B
zd9@`)vAcl&?_^h1Ji8tv53r-(IkCCc^|3^~|VF@*!H
z{TKpoMX|T0{k^V==eL7GyF3IkMv|WC+0xFZ?0rypU(sz@k&n$!=<)-HCtxQ
za6D%LwUfTjHtR5UmA1J^O$?hes@nT~ehk1~#sD?c;0@Rnlt#a&Qd&I_pCNgg)Ysxi
zfaxH1ExAKRW5h)<=8{=o9!9hL3#O)n9V+j#1y9TDg^4bKC6ndG4z06}ri|36bbb?5
z?>NE<`Bj%ElN_g9!Rsf{E2(I@+UwRZ!fZM8=}&qBnLNu!7KkTd%L3f=uOcO5V_8kOJUyN_mZy6X)a{^QNEyz1
zRD=k=>jO)g2a*1ye0*17Gc+{Ejl(=sn=X?FqhiE1T2Cpa0~8*R_prf(u3OHQH0HOn
zENrn}s-`MEIgeh02iM2?Cp%2mr}3ID>+_2an44}aWJ(5~H{w}&P<{@DmUm}K>2!YC
zkB}k9Lhu!Lv2i)L8OUPh)fdL^o-d=@lN~g6Xj1W$JP>`yX9(AYYH;W<%Y3Zl1Mh^n? x5FlxBOc>3~W7|~jnz^7`Gw&@6yrb>GrEeGF(
z?QN88`52LBeU1dhBmTFybG5QITwqzlsMIb1q&;@6a;|v~-IbPPU7~%tQULyP)BZN#J2FXgd*PeIPKs_T1YL`u<9}bG9vJnPZ0rdZXI`NmWj_;Htl626@vDcOPk%;OlnQ@Z5Kk;PHbtnM)3G3~Tpja1pY
zL3(NEZ`K6;-!>BhJvQD=;L(#Fn&Lp4f93f8DNA-C1KO=d74$3P%qw*A-e@<#iU0ia
zLRHd_vNrC3#}#6THFdtFpV>It!bQ+EX+4sEfgZ3LJWqvWj9qE?C_GrTen!`@=6<2M
zd$T~BLzbePsvi~t7j|HQMDDwZ!ajps<{^9sDyG#1rGpI|@3!FTmt3m__WP=tpYHRx
z#mt0XjVw2Ed2+~!J)9jpfRyIDu6O|Q4r-5}FyPT-#J~fRj?|1_p7l_d
zdhdDuDt?1EYTL(#fqf~b_=h{+vSnOt6@b@3RNlnof|o+{{L$&2V@}fyUiK-x8o?6x
z-0@haF;3g@ow21nT^!PzkAN%uZ}_eSe?N1N7t=jYp76tIu2KEd*`i*ZJWPXu7bQim
zF?Gqo{-~-I$s3s{vhHJII&K9jpxr|qM#PgJRC<WLAr>|lDjK@OOq$u
zi^I8$(}#Qiy;ndr-47)5;{_3H!H}%Chi;M@4t+XTuKNpCac?e^x{%iS7ie6~H8BKN
zO(8sWLm36zkC7qd*1aRV?w48?pN))7#)IZpES~O>_|-)sufKOFmD1GzN3yyWNSj(I$)q?Dfbu6BM?TPX7r7fPrOoi5_u#&9T*b
zno4j_&$hnh@A0f-V!Ju)#^J@*V!MgN=#YJ^@SiZ-b1pdS(mTPy(wh4tGOLGZpUF
zo)HuAio8%az$T_q!_z9~GI{_8eG_)ZUTVvxg4NAZ+4Iib)m>2!(M?eVqPZMfi;*li
zISKb$*Bl-@u&r9dKczVZR!~{rSef&cL~yQ5=1B<|_$*kct2LZEyax1ioe5
z>%)wrnd7c2HDN3dJ-E16s7!s!y0Z3*Q@^CV<
zXGpZ(a7F!$OYmboGx2p6QYV_Sg?bHX{LXqgj*H{rmn+;$Lm3%XZj*;9r#upFgne~n
z=}ogHdN$Z1gIxaGLPrLNbAJ=w`X&@)qQuH_z{i=088X$OA_2;8Ec|3kyIC5uOSs%okC!lcX+ITi(&&
zdnq&Qe_GbG^OnlasnT)<^@T(7CM)8u=^pmzutsf)gP&i0v-EVo8kREvG2uy(rS
zb9<$I-}B+3mx*%?;9jB~RA-2yPrj;88zjhXc#d8RyK>%0Afg-dP18iIqDy4pZj&A$
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