22 KiB
Phase 4: Review, Download & Security — Research
Researched: 2026-03-27 Domain: Browser-side file generation, download triggers, clipboard API, ZIP bundling, security UX, React wizard step Confidence: HIGH
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CONF-02 | User sees a live preview of the generated rclone.conf content as they fill the form | buildRcloneConf(state) already exists — call it in a React component that subscribes to WizardContext; update on every state change via useMemo |
| CONF-03 | User can copy the rclone.conf content to clipboard | navigator.clipboard.writeText(text) — no library needed; HTTPS / localhost required |
| DOWN-01 | User can download the rclone.conf file individually | Blob download helper: new Blob([content], { type: 'text/plain' }) + anchor click |
| DOWN-02 | User can download the Intune install script individually | Same Blob helper, filename intune-install.ps1 |
| DOWN-03 | User can download the Intune detection script individually | Same Blob helper, filename intune-detection.ps1 |
| DOWN-04 | User can download the RMM script individually | Same Blob helper, filename rmm-script.ps1 |
| DOWN-05 | User can download all artifacts as a single ZIP bundle | JSZip generateAsync({ type: 'blob' }) + anchor click; no FileSaver needed |
| DOWN-06 | User can copy any output block to clipboard directly | Same navigator.clipboard.writeText as CONF-03, applied per-block |
| SECU-01 | Security warning gate — credentials are plaintext — user must acknowledge before downloads enabled | Checkbox + boolean gate on all download buttons; no library needed |
| SECU-02 | App prominently states no data is sent to any server | Static banner / badge in ReviewStep UI |
| SECU-03 | Wizard state never persisted to localStorage, sessionStorage, or any external service | Already enforced by types.ts SECURITY comment and pure in-memory useReducer; verification test needed |
| </phase_requirements> |
Summary
Phase 4 is the final layer of the Ready2Blob wizard. It adds a fourth wizard step (ReviewStep, step index 3) that renders live-generated file content, enforces a security acknowledgement gate, exposes individual download buttons, a clipboard copy button per output block, and a ZIP bundle download. All operations are client-side only.
The project already has all four generator functions (buildRcloneConf, buildIntuneInstall, buildIntuneDetection, buildRmmScript) exported from src/generators/index.ts. The wizard state is in-memory React context (useReducer). App.tsx already clamps step index and is explicitly annotated "phase 4 will add step 3" — the extension point is ready.
The two non-trivial browser APIs are (1) blob URL download (zero dependencies, native since 2012) and (2) JSZip for the ZIP bundle (the only new dependency this phase adds). Clipboard is a native async API — no library needed.
Primary recommendation: Add one new component ReviewStep, wire it as step 3 in App.tsx, and introduce JSZip as the single new runtime dependency. Everything else is native browser APIs on top of existing generator functions.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| React + useContext | 18.3 (already installed) | State access in ReviewStep | Already the project state pattern |
| Native Blob API | Browser built-in | File content → downloadable file | No dependency, ~100% modern browser support |
navigator.clipboard |
Browser built-in | Copy text to clipboard | Modern async API, secure-context only |
| JSZip | ^3.10.1 | Client-side ZIP generation | 14M+ weekly downloads, ships own TS types, zero native dependencies |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
URL.createObjectURL |
Browser built-in | Turn a Blob into a downloadable URL | Used inside the blob download helper |
URL.revokeObjectURL |
Browser built-in | Release Blob memory after download | Must call after anchor click to prevent memory leak |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| JSZip | client-zip (Touffy) |
client-zip is streaming and lighter but less documented; JSZip is the established standard |
| JSZip | FileSaver.js | FileSaver is a wrapper only, adds no value when you already have a Blob — not needed |
navigator.clipboard |
document.execCommand('copy') |
execCommand is deprecated; clipboard API is the correct modern approach |
Installation (new dependency only):
npm install jszip
JSZip ships its own TypeScript declarations. No @types/jszip needed (that package is 6 years stale).
Architecture Patterns
Recommended Project Structure
src/
├── components/
│ └── wizard/
│ ├── ReviewStep.tsx # new — step 3, main Phase 4 component
│ └── OutputBlock.tsx # new — reusable code block with copy + download buttons
├── generators/
│ └── index.ts # unchanged — already exports all four generators
└── App.tsx # update — add ReviewStep as step index 3
Pattern 1: Blob Download Helper (pure utility function)
What: A standalone function that accepts file content (string), MIME type, and filename, creates a Blob, generates an object URL, programmatically clicks a hidden anchor, then revokes the URL.
When to use: DOWN-01, DOWN-02, DOWN-03, DOWN-04 (individual file downloads)
// src/utils/downloadFile.ts
export function downloadFile(content: string, filename: string, mimeType = 'text/plain'): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
Pattern 2: ZIP Bundle Download (JSZip)
What: Calls all four generators, adds each output to a JSZip instance, generates a Blob asynchronously, then triggers download via the same Blob/anchor pattern.
When to use: DOWN-05 (ZIP bundle)
// Source: https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html
import JSZip from 'jszip';
import { downloadFile } from '../utils/downloadFile';
async function downloadZip(state: WizardState): Promise<void> {
const zip = new JSZip();
zip.file('rclone.conf', buildRcloneConf(state));
zip.file('intune-install.ps1', buildIntuneInstall(state));
zip.file('intune-detection.ps1', buildIntuneDetection(state));
zip.file('rmm-script.ps1', buildRmmScript(state));
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'rclone-deployment.zip';
a.click();
URL.revokeObjectURL(url);
}
Pattern 3: Clipboard Copy (navigator.clipboard)
What: Async call to navigator.clipboard.writeText(text). Must be triggered by a user gesture (button click). Requires secure context (HTTPS or localhost — both are satisfied in dev and production for this app).
When to use: CONF-03, DOWN-06 (copy any output block)
// Source: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText
async function copyToClipboard(text: string): Promise<void> {
await navigator.clipboard.writeText(text);
}
For UI feedback (button label changes to "Copied!" for 2 seconds), use a local useState boolean in the OutputBlock component — no external library needed.
Pattern 4: Security Gate (SECU-01)
What: A React useState boolean acknowledged controls whether all download buttons are enabled. The user must check a checkbox to set it true. Buttons use disabled={!acknowledged}.
When to use: All download and copy buttons are gated behind this — render them, but disable until acknowledged.
const [acknowledged, setAcknowledged] = useState(false);
// Warning UI
<label>
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
/>
I understand that the generated files contain credentials in plain text.
</label>
// Download button
<button disabled={!acknowledged} onClick={() => downloadFile(content, filename)}>
Download
</button>
Pattern 5: Live Config Preview (CONF-02)
What: Call buildRcloneConf(state) inside a useMemo that depends on the full wizard state. Render in a <pre> / <code> block. Because WizardContext updates on every dispatch, the preview updates in real time as the user fills in the form.
When to use: CONF-02 (live preview of rclone.conf)
const { state } = useWizard();
const rcloneConfContent = useMemo(() => {
try {
return buildRcloneConf(state);
} catch {
// backendType or name not yet set — show placeholder
return '# Fill in the wizard to generate your rclone.conf';
}
}, [state]);
buildRcloneConf throws when backendType is null or name is empty — the try/catch makes the preview gracefully show a placeholder during incomplete form states.
Pattern 6: Wiring ReviewStep into App.tsx
App.tsx already clamps stepIndex to steps.length - 1. To add step 3, import ReviewStep and add it to the steps array. No other changes needed.
// App.tsx change — add one import and one array entry
import { ReviewStep } from './components/wizard/ReviewStep';
const steps = [
<BackendSelectionStep key="backend" />,
<RemoteConfigStep key={state.remote.backendType ?? 'none'} />,
<DeploymentStep key="deployment" />,
<ReviewStep key="review" />, // step index 3
];
StepIndicator STEP_LABELS must also gain a fourth entry: 'Review'.
Anti-Patterns to Avoid
- Adding FileSaver.js: Unnecessary —
URL.createObjectURL+ anchor click achieves the same result with zero dependencies. - Importing from individual generator files: Always import from
src/generators/index.tsbarrel — the barrel was explicitly annotated for Phase 4 use. - Calling generators unconditionally when state is incomplete: Wrap in try/catch (or check
state.remote.backendType !== null) to prevent thrown errors crashing the ReviewStep render. - Persisting state to localStorage for "convenience": SECU-03 explicitly forbids this. The types.ts file has a SECURITY comment to this effect.
- Showing downloads before acknowledgement: All download and copy-to-clipboard actions must be disabled until the SECU-01 checkbox is checked.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| ZIP archive creation | Custom ZIP binary writer | JSZip generateAsync |
ZIP format has CRC32, compression, local/central directory headers — complex binary encoding |
| Clipboard write | document.execCommand('copy') |
navigator.clipboard.writeText() |
execCommand is deprecated in all major browsers as of 2023 |
| File download | Server-side download endpoint | Blob + createObjectURL pattern | No server exists; Blob approach is idiomatic for pure-frontend apps |
Key insight: All three "hard-looking" problems (ZIP, clipboard, download) have mature browser-native or single-library solutions. The actual complexity in Phase 4 is UI composition and security gate wiring, not the download mechanics.
Common Pitfalls
Pitfall 1: buildRcloneConf throws on incomplete state
What goes wrong: buildRcloneConf throws Error: backendType is required when called before the user has selected a backend. If called unconditionally during render, this crashes the component.
Why it happens: ReviewStep will be mounted as step 3, but the user could theoretically arrive with incomplete state (or state is observed mid-dispatch).
How to avoid: Always wrap generator calls in try/catch in the preview/render path. For download buttons, additionally disable them when state.remote.backendType === null.
Warning signs: Uncaught error in console when navigating to step 3 without completing earlier steps.
Pitfall 2: URL.revokeObjectURL called too early
What goes wrong: The download is cancelled or fails if revokeObjectURL is called synchronously before the browser has processed the anchor click.
Why it happens: a.click() is synchronous but the browser's download pipeline is asynchronous. In practice the click initiates the download before revocation, but best practice is to revoke in a setTimeout with 0ms delay or immediately after click (the URL stays valid just long enough).
How to avoid: Call URL.revokeObjectURL(url) immediately after a.click() — modern browsers queue the download before processing the revocation. This is the standard pattern per MDN.
Pitfall 3: navigator.clipboard requires secure context
What goes wrong: navigator.clipboard is undefined in HTTP contexts (not localhost, not HTTPS).
Why it happens: Browser security policy — clipboard write access is restricted to secure origins.
How to avoid: In production this app will be served over HTTPS. In development, Vite's dev server runs on localhost which is a secure context. No workaround needed. If testing outside localhost, use HTTPS.
Warning signs: Cannot read properties of undefined (reading 'writeText') in the console.
Pitfall 4: Vitest/jsdom lacks URL.createObjectURL
What goes wrong: Tests that call downloadFile() or downloadZip() throw TypeError: URL.createObjectURL is not a function because jsdom does not implement it.
Why it happens: jsdom omits browser download/blob APIs that have no meaningful DOM simulation.
How to avoid: Mock URL.createObjectURL and URL.revokeObjectURL in test setup or per-test: vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() }). Test the generator output separately (already tested in Phase 2), test that the download function was called with the right arguments.
Pitfall 5: Intune/RMM scripts not in ZIP when scriptTargets is partial
What goes wrong: User unchecked 'rmm' in DeploymentStep — the ZIP bundle should omit the RMM script, or should it always include all four files?
Why it happens: The requirements say "all artifacts as a ZIP bundle" (DOWN-05) without specifying conditional inclusion.
How to avoid: Simplest and safest: always include all four files in the ZIP. The deployment options (scriptTargets) control what the generated scripts DO, not what files appear. This avoids confusing partial ZIPs and matches the "download any combination" goal.
Code Examples
Verified patterns from browser platform APIs and JSZip official docs:
Individual File Download (no dependency)
// Source: MDN Web Docs — https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static
export function downloadFile(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
ZIP Bundle Download (JSZip)
// Source: https://stuk.github.io/jszip/documentation/examples/download-zip-file.html (adapted)
import JSZip from 'jszip';
export async function downloadZip(files: { name: string; content: string }[], zipName: string): Promise<void> {
const zip = new JSZip();
for (const { name, content } of files) {
zip.file(name, content);
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = zipName;
a.click();
URL.revokeObjectURL(url);
}
Clipboard Copy with Feedback
// Source: MDN — https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText
import { useState } from 'react';
export function useCopyToClipboard() {
const [copied, setCopied] = useState(false);
async function copy(text: string) {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return { copy, copied };
}
SECU-03 Verification Pattern (test)
// Verify localStorage and sessionStorage are never touched
it('SECU-03: wizard state never written to storage', () => {
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
// render full app, simulate dispatch actions
expect(setItemSpy).not.toHaveBeenCalled();
});
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
document.execCommand('copy') |
navigator.clipboard.writeText() |
Deprecated ~2020, removed intent Chrome 2023 | Must use async Clipboard API |
| FileSaver.js for downloads | Native Blob + createObjectURL | Blob API well-supported since 2013 | No extra dependency for downloads |
@types/jszip separate package |
JSZip ships own TypeScript types | JSZip 3.x series | Only install jszip, not @types/jszip |
Deprecated/outdated:
document.execCommand('copy'): Deprecated. Do not use.@types/jszip: Last published 6 years ago, stale — JSZip 3.x includes its own types.
Open Questions
-
Should the ZIP always include all 4 files regardless of
scriptTargets?- What we know: DOWN-05 says "all artifacts as a single ZIP bundle";
scriptTargetscontrols which scripts are generated but not what the user can download. - What's unclear: Whether omitting unchecked script targets from the ZIP is a desirable UX or confusing.
- Recommendation: Always include all four files in the ZIP. Simpler, less surprising.
- What we know: DOWN-05 says "all artifacts as a single ZIP bundle";
-
Should individual download buttons for Intune/RMM scripts be hidden when that target is unchecked in DeploymentStep?
- What we know: Requirements say "download each artifact individually" without conditionality.
- Recommendation: Show all four download buttons always. A user may want to download an Intune script even if they unchecked it during deployment config.
-
Does
buildRcloneConfneed to be called defensively in the live preview when the backend form is only partially filled?- What we know:
buildRcloneConfthrows only for null backendType or empty name — partial params (e.g., empty password) are allowed (they're filtered out withif (value !== '')). - Recommendation: Wrap in try/catch; show placeholder text when thrown. Separate issue from download gate.
- What we know:
Validation Architecture
nyquist_validation is enabled in .planning/config.json.
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest ^4.1.1 |
| Config file | vite.config.ts (test block, environment: jsdom) |
| Quick run command | npm test |
| Full suite command | npm test |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CONF-02 | rclone.conf preview updates when state changes | unit | npm test -- --reporter=verbose src/components/wizard/ReviewStep.test.tsx |
Wave 0 |
| CONF-03 | clipboard.writeText called with correct conf content | unit | same file | Wave 0 |
| DOWN-01 | downloadFile called with rclone.conf content and filename | unit | same file | Wave 0 |
| DOWN-02 | downloadFile called with intune-install.ps1 content | unit | same file | Wave 0 |
| DOWN-03 | downloadFile called with intune-detection.ps1 content | unit | same file | Wave 0 |
| DOWN-04 | downloadFile called with rmm-script.ps1 content | unit | same file | Wave 0 |
| DOWN-05 | zip.generateAsync called; anchor download triggered with .zip name | unit | same file | Wave 0 |
| DOWN-06 | clipboard.writeText called for each output block's copy button | unit | same file | Wave 0 |
| SECU-01 | download buttons disabled when acknowledged=false; enabled after check | unit | same file | Wave 0 |
| SECU-02 | "no data sent to server" text present in ReviewStep render | unit | same file | Wave 0 |
| SECU-03 | localStorage.setItem / sessionStorage.setItem never called | unit | npm test -- src/store/reducer.test.ts |
exists (add assertion) |
Sampling Rate
- Per task commit:
npm test - Per wave merge:
npm test - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
src/components/wizard/ReviewStep.test.tsx— covers CONF-02, CONF-03, DOWN-01 through DOWN-06, SECU-01, SECU-02- Mock setup for
URL.createObjectURL,URL.revokeObjectURL,navigator.clipboard.writeText— required for download and clipboard tests in jsdom src/utils/downloadFile.ts— utility referenced by tests (created in implementation wave)
(SECU-03 can be tested by adding a Storage.prototype.setItem spy to the existing reducer.test.ts)
Sources
Primary (HIGH confidence)
- JSZip official docs — https://stuk.github.io/jszip/documentation/examples/download-zip-file.html — generateAsync API, blob download example
- JSZip generateAsync API — https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html — type:'blob' parameter
- MDN Clipboard API — https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API — writeText, secure context requirement
- MDN Blob — https://developer.mozilla.org/en-US/docs/Web/API/Blob — Blob constructor, type parameter
- Project source files (generators/index.ts, store/types.ts, App.tsx, vite.config.ts) — confirmed existing API surfaces
Secondary (MEDIUM confidence)
- JSZip npm page — https://www.npmjs.com/package/jszip — version 3.10.1, weekly downloads, TypeScript types bundled
- LogRocket Clipboard API in React — https://blog.logrocket.com/implementing-copy-clipboard-react-clipboard-api/ — React pattern confirmation
- CoreUI file downloader in React — https://coreui.io/answers/how-to-create-a-file-downloader-in-react/ — Blob + anchor pattern confirmation
Tertiary (LOW confidence)
- None — all claims verified against official docs or project source.
Metadata
Confidence breakdown:
- Standard stack: HIGH — JSZip is the dominant client-side ZIP library; Blob/Clipboard are native APIs documented on MDN
- Architecture: HIGH — generator functions and WizardContext are already implemented; extension points are explicit in source code
- Pitfalls: HIGH — jsdom URL.createObjectURL gap is a well-known vitest/jest issue; the other pitfalls derive from the existing codebase
Research date: 2026-03-27 Valid until: 2026-06-27 (stable APIs — Blob, Clipboard, JSZip 3.x all in maintenance mode)