mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Merge reviewed configuration-transfer safety candidate for CI repair
Change-source: pulse-maintainer
This commit is contained in:
@@ -1864,6 +1864,9 @@ removal/failure retention and containing publication remain separate evidence.
|
||||
142. `frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts`
|
||||
143. `frontend-modern/src/i18n/__tests__/i18n.test.ts`
|
||||
|
||||
- `frontend-modern/src/components/Settings/BackupTransferDialogs.tsx`
|
||||
- `frontend-modern/src/components/Settings/useBackupTransferFlow.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
### Transport-independent operator rows
|
||||
@@ -9997,3 +10000,32 @@ transcript block moved to `frontend-modern/src/components/patrol/` with its
|
||||
`CopyValueButton` composition intact. The External agents settings page lives
|
||||
at `/settings/pulse-intelligence/agents`; the old `/assistant` path redirects
|
||||
there.
|
||||
|
||||
### Configuration transfers retain their confirmed workspace
|
||||
|
||||
Recovery's export/import dialogs capture and display the effective workspace ID
|
||||
when opened, including explicit `default` scope. Their production flow freezes
|
||||
inputs before asynchronous file reads, binds both POSTs through the API client's
|
||||
`expectedOrgID` and `retry: false`, and rejects a changed workspace before dispatch
|
||||
(including the client's post-CSRF recheck). An invalid organisation must never
|
||||
fall back to default, and a CSRF refusal must not replay a transfer. Only one
|
||||
transfer can be pending; controls and dismissal remain disabled while waiting.
|
||||
|
||||
Token entry saves a credential without submitting either transfer. The operator
|
||||
must review the target and submit explicitly, including after a server refusal.
|
||||
A failed/unknown import response is not proof that nothing applied: persistent
|
||||
in-dialog guidance requires checking the destination before another attempt.
|
||||
Exports discard late data after a workspace change; successful imports identify
|
||||
the original workspace and never reload a different one. Leaving Settings aborts
|
||||
the client's wait and suppresses late downloads, notices and reload timers; it
|
||||
does not promise to undo a server-side import or prevent transport-level replay.
|
||||
A non-retrying application fetch is not server-side single-application protection.
|
||||
Existing archive formats,
|
||||
authorisation, scope and encrypted payloads remain unchanged.
|
||||
|
||||
`useBackupTransferFlow.test.ts` runs the real API client across delayed file/CSRF
|
||||
preparation, scope switches, duplicate calls, non-retryable failures, token entry,
|
||||
unmount, archive compatibility and cleanup. `BackupTransferDialogs.test.tsx`
|
||||
checks target/error disclosure and pending input/action states. Browser evidence
|
||||
must exercise the production flow and consuming dialogs at desktop and narrow
|
||||
widths with safe responses, not a replacement transfer implementation.
|
||||
|
||||
@@ -5468,6 +5468,7 @@
|
||||
"frontend-modern/src/components/Settings/AuditLogPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/AuditWebhookPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/CopyCommandBlock.tsx",
|
||||
"frontend-modern/src/components/Settings/DataHandlingPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/dataHandlingPanelModel.ts",
|
||||
@@ -5512,6 +5513,7 @@
|
||||
"frontend-modern/src/components/Settings/useAISettingsState.ts",
|
||||
"frontend-modern/src/components/Settings/useAuditLogPanelState.ts",
|
||||
"frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts",
|
||||
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts",
|
||||
"frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts",
|
||||
"frontend-modern/src/components/Settings/useDiscoverySettingsState.ts",
|
||||
"frontend-modern/src/components/Settings/useReportingPanelState.ts",
|
||||
@@ -5583,6 +5585,21 @@
|
||||
"exact_files": [],
|
||||
"require_explicit_path_policy_coverage": true,
|
||||
"path_policies": [
|
||||
{
|
||||
"id": "settings-configuration-transfer",
|
||||
"label": "configuration transfer target and dispatch proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/useBackupTransferFlow.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "settings-update-install-guide",
|
||||
"label": "settings update install guide proof",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d051c39bca60ba8cfbda0d07a07b74c228bc7eae",
|
||||
"verified_at": "2026-10-25T06:19:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/Settings.tsx",
|
||||
"frontend-modern/src/components/Settings/SettingsDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "48021e33b8243aa08e964bfa8d00dd458d862542290717bb1819cada34fb8bc1",
|
||||
"frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx": "5326391538b6dd5abdb795753e645c32483a24947f91c2edaf03cdbfd28dd9db",
|
||||
"frontend-modern/src/components/Settings/Settings.tsx": "fc11be5e7072f7c6e2c3e1004ead39aeb760aa9df689dc9f818dad1fdd0f4fcf",
|
||||
"frontend-modern/src/components/Settings/SettingsDialogs.tsx": "3369aaaef68ef2fb84c448674a5b93dd8058994d4c35db44b09033123a4b2582",
|
||||
"frontend-modern/src/components/Settings/useBackupTransferFlow.ts": "e114bdad70b09349aee656b36a0abd1ad9b80ce1a4343fb9333f6d664ed9b76d"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/system/recovery (offline fixture mounting production RecoverySettingsPanel, useBackupTransferFlow and SettingsDialogs/BackupTransferDialogs with real apiClient, Dialog, ToastContainer and CSS; not full app/auth)"
|
||||
],
|
||||
"states": [
|
||||
"Workspace target retained from dialog open; changed scope before submit and during file read refuses dispatch; late export discarded; successful old-workspace import cannot reload the new selection.",
|
||||
"One pending operation disables inputs, actions and dismissal; immutable file/passphrase sent to captured workspace.",
|
||||
"HTTP invalid_org, CSRF and rate-limit responses make one application fetch and one server request, no default fallback or false success.",
|
||||
"Token preflight and server-token-error prompts save a credential without submitting; deliberate manual submission remains usable.",
|
||||
"Transport reset: one application fetch produces three same-workspace wire requests in each viewport; import remains unconfirmed and may have applied. This is not single-application protection.",
|
||||
"Unmount during file read suppresses dispatch; populated export yields one byte-exact synthetic encrypted-envelope download at each viewport."
|
||||
],
|
||||
"interactions": [
|
||||
"Desktop keyboard focus/Enter and narrow touch open the existing recovery dialogs; select file, enter masked passphrase and submit.",
|
||||
"Delay original server responses and file reads; switch selected organisation via the real apiClient setter; assert captured scope and late-response suppression.",
|
||||
"Inspect visible target, pending and persistent uncertain guidance; save a synthetic API token and explicitly submit, never auto-submit.",
|
||||
"85 browser checks, 24 server request observations, two byte-exact downloads; all ten settled desktop/narrow captures inspected with native view_image."
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000,
|
||||
"browser": "Chromium",
|
||||
"browser_version": "141.0.7390.37"
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 940,
|
||||
"browser": "Chromium",
|
||||
"browser_version": "141.0.7390.37",
|
||||
"touch": true
|
||||
}
|
||||
],
|
||||
"evidence": "Current lane OUTCOME/RECEIPTS.md, browser-result.json, PIXEL-REVIEW.md and BROWSER-BINDING.json; Playwright 1.56.1",
|
||||
"limitations": [
|
||||
"Safe synthetic responses exercise the real frontend producer/consumer, not backend auth, archive cryptography, full app shell, private runtime, native recovery or release delivery.",
|
||||
"Application retry opt-out is not server-side idempotency; Core assessment/repair retains the observed transport replay gap."
|
||||
]
|
||||
}
|
||||
@@ -21,6 +21,9 @@ interface BackupTransferDialogsProps {
|
||||
showExportDialog: Accessor<boolean>;
|
||||
showImportDialog: Accessor<boolean>;
|
||||
showApiTokenModal: Accessor<boolean>;
|
||||
transferOrgID: Accessor<string>;
|
||||
isTransferring: Accessor<boolean>;
|
||||
transferError: Accessor<string>;
|
||||
apiTokenInput: Accessor<string>;
|
||||
setApiTokenInput: (value: string) => void;
|
||||
handleExport: () => void;
|
||||
@@ -43,6 +46,19 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
>
|
||||
<Card padding="lg" class="max-w-md w-full">
|
||||
<SectionHeader title="Export configuration" size="md" class="mb-4" />
|
||||
<p class="mb-4 text-sm text-muted break-all">
|
||||
Workspace ID: <strong class="text-base-content">{props.transferOrgID()}</strong>
|
||||
</p>
|
||||
<Show when={props.transferError()}>
|
||||
<p role="alert" class="mb-4 text-sm text-red-700 dark:text-red-300">
|
||||
{props.transferError()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.isTransferring()}>
|
||||
<p role="status" class="mb-4 text-sm text-muted">
|
||||
Exporting configuration. Keep this dialog open while Pulse waits for a response.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Show when={props.securityStatus()?.hasAuthentication}>
|
||||
@@ -51,6 +67,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={props.isTransferring()}
|
||||
checked={!props.useCustomPassphrase()}
|
||||
onChange={() => {
|
||||
props.setUseCustomPassphrase(false);
|
||||
@@ -71,6 +88,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={props.isTransferring()}
|
||||
checked={props.useCustomPassphrase()}
|
||||
onChange={() => props.setUseCustomPassphrase(true)}
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500"
|
||||
@@ -98,6 +116,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
</label>
|
||||
<input
|
||||
id="backup-export-passphrase"
|
||||
disabled={props.isTransferring()}
|
||||
type="password"
|
||||
value={props.exportPassphrase()}
|
||||
onInput={(event) => props.setExportPassphrase(event.currentTarget.value)}
|
||||
@@ -165,6 +184,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.closeExportDialog}
|
||||
disabled={props.isTransferring()}
|
||||
class="px-4 py-2 border border-border text-base-content rounded-md hover:bg-surface-hover"
|
||||
>
|
||||
Cancel
|
||||
@@ -173,6 +193,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
type="button"
|
||||
onClick={props.handleExport}
|
||||
disabled={
|
||||
props.isTransferring() ||
|
||||
!props.exportPassphrase() ||
|
||||
(props.useCustomPassphrase() && props.exportPassphrase().length < 12)
|
||||
}
|
||||
@@ -199,7 +220,8 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted">
|
||||
This Pulse instance requires an API token for export/import operations. Please enter
|
||||
the API token configured on the server.
|
||||
a token authorised for this workspace. Saving it does not repeat the transfer;
|
||||
review the workspace and submit again yourself.
|
||||
</p>
|
||||
|
||||
<div class={formField}>
|
||||
@@ -254,6 +276,19 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
>
|
||||
<Card padding="lg" class="max-w-md w-full">
|
||||
<SectionHeader title="Import configuration" size="md" class="mb-4" />
|
||||
<p class="mb-4 text-sm text-muted break-all">
|
||||
Workspace ID: <strong class="text-base-content">{props.transferOrgID()}</strong>
|
||||
</p>
|
||||
<Show when={props.transferError()}>
|
||||
<p role="alert" class="mb-4 text-sm text-red-700 dark:text-red-300">
|
||||
{props.transferError()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.isTransferring()}>
|
||||
<p role="status" class="mb-4 text-sm text-muted">
|
||||
Importing configuration. Keep this dialog open while Pulse waits for a response.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class={formField}>
|
||||
@@ -262,6 +297,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
</label>
|
||||
<input
|
||||
id="backup-import-file"
|
||||
disabled={props.isTransferring()}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={(event) => {
|
||||
@@ -278,6 +314,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
</label>
|
||||
<input
|
||||
id="backup-import-passphrase"
|
||||
disabled={props.isTransferring()}
|
||||
type="password"
|
||||
value={props.importPassphrase()}
|
||||
onInput={(event) => props.setImportPassphrase(event.currentTarget.value)}
|
||||
@@ -316,6 +353,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.closeImportDialog}
|
||||
disabled={props.isTransferring()}
|
||||
class="px-4 py-2 border border-border text-base-content rounded-md hover:bg-surface-hover"
|
||||
>
|
||||
Cancel
|
||||
@@ -323,7 +361,9 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.handleImport}
|
||||
disabled={!props.importPassphrase() || !props.importFile()}
|
||||
disabled={
|
||||
props.isTransferring() || !props.importPassphrase() || !props.importFile()
|
||||
}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Import
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface RecoverySettingsPanelProps {
|
||||
|
||||
// Export/Import handlers
|
||||
showExportDialog: Accessor<boolean>;
|
||||
setShowExportDialog: Setter<boolean>;
|
||||
setShowExportDialog: (open: boolean) => void;
|
||||
showImportDialog: Accessor<boolean>;
|
||||
setShowImportDialog: Setter<boolean>;
|
||||
setShowImportDialog: (open: boolean) => void;
|
||||
setUseCustomPassphrase: Setter<boolean>;
|
||||
securityStatus: Accessor<{ hasAuthentication: boolean } | null>;
|
||||
}
|
||||
|
||||
@@ -228,6 +228,9 @@ const SettingsWorkspace: Component<SettingsProps> = (props) => {
|
||||
showExportDialog={backupTransferFlow.showExportDialog}
|
||||
showImportDialog={backupTransferFlow.showImportDialog}
|
||||
showApiTokenModal={backupTransferFlow.showApiTokenModal}
|
||||
transferOrgID={backupTransferFlow.transferOrgID}
|
||||
isTransferring={backupTransferFlow.isTransferring}
|
||||
transferError={backupTransferFlow.transferError}
|
||||
apiTokenInput={backupTransferFlow.apiTokenInput}
|
||||
setApiTokenInput={backupTransferFlow.setApiTokenInput}
|
||||
handleExport={backupTransferFlow.handleExport}
|
||||
|
||||
@@ -25,6 +25,9 @@ interface SettingsDialogsProps {
|
||||
showExportDialog: Accessor<boolean>;
|
||||
showImportDialog: Accessor<boolean>;
|
||||
showApiTokenModal: Accessor<boolean>;
|
||||
transferOrgID: Accessor<string>;
|
||||
isTransferring: Accessor<boolean>;
|
||||
transferError: Accessor<string>;
|
||||
apiTokenInput: Accessor<string>;
|
||||
setApiTokenInput: (value: string) => void;
|
||||
handleExport: () => void;
|
||||
@@ -72,6 +75,9 @@ export const SettingsDialogs: Component<SettingsDialogsProps> = (props) => {
|
||||
showExportDialog={props.showExportDialog}
|
||||
showImportDialog={props.showImportDialog}
|
||||
showApiTokenModal={props.showApiTokenModal}
|
||||
transferOrgID={props.transferOrgID}
|
||||
isTransferring={props.isTransferring}
|
||||
transferError={props.transferError}
|
||||
apiTokenInput={props.apiTokenInput}
|
||||
setApiTokenInput={props.setApiTokenInput}
|
||||
handleExport={props.handleExport}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BackupTransferDialogs } from '../BackupTransferDialogs';
|
||||
|
||||
function renderTransfer(kind: 'export' | 'import') {
|
||||
function renderTransfer(kind: 'export' | 'import', pending = false, error = '') {
|
||||
return render(() => (
|
||||
<BackupTransferDialogs
|
||||
securityStatus={() => null}
|
||||
@@ -17,6 +17,9 @@ function renderTransfer(kind: 'export' | 'import') {
|
||||
showExportDialog={() => kind === 'export'}
|
||||
showImportDialog={() => kind === 'import'}
|
||||
showApiTokenModal={() => false}
|
||||
transferOrgID={() => 'tenant-fixture'}
|
||||
isTransferring={() => pending}
|
||||
transferError={() => error}
|
||||
apiTokenInput={() => ''}
|
||||
setApiTokenInput={vi.fn()}
|
||||
handleExport={vi.fn()}
|
||||
@@ -63,4 +66,28 @@ describe('BackupTransferDialogs', () => {
|
||||
expect(guide).toHaveAttribute('target', '_blank');
|
||||
expect(guide).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it.each(['export', 'import'] as const)(
|
||||
'shows the fixed workspace, disables pending %s inputs and exposes progress',
|
||||
(kind) => {
|
||||
renderTransfer(kind, true);
|
||||
expect(screen.getByText('tenant-fixture')).toBeInTheDocument();
|
||||
const dialog = screen.getByRole('dialog');
|
||||
for (const control of dialog.querySelectorAll('input, button')) {
|
||||
expect(control).toBeDisabled();
|
||||
}
|
||||
expect(screen.getByRole('status')).toHaveTextContent('while Pulse waits for a response');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps uncertain outcome guidance readable in the import dialog', () => {
|
||||
renderTransfer(
|
||||
'import',
|
||||
false,
|
||||
'Import was not confirmed. Check the destination before trying again.',
|
||||
);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Check the destination before trying again',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,13 @@ const settingsRuntimeSources = import.meta.glob(['../*.tsx', '../ConnectionEdito
|
||||
}) as Record<string, string>;
|
||||
|
||||
describe('settings architecture guardrails', () => {
|
||||
it('carries transfer target, pending and error state through the Settings dialog shell', () => {
|
||||
for (const name of ['transferOrgID', 'isTransferring', 'transferError']) {
|
||||
expect(settingsSource).toContain(`${name}={backupTransferFlow.${name}}`);
|
||||
expect(settingsDialogsSource).toContain(`${name}={props.${name}}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the retired Assistant chat controls and protected guests list out of AI settings', () => {
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('Chat action mode');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('controlLevel');
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { createRoot } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
import { apiClient, clearApiToken, getApiToken, getOrgID, setOrgID } from '@/utils/apiClient';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { SecurityStatus } from '@/types/config';
|
||||
import { useBackupTransferFlow } from '../useBackupTransferFlow';
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const disposers: (() => void)[] = [];
|
||||
const passphrase = 'synthetic-passphrase';
|
||||
let timeoutSpy: MockInstance<typeof globalThis.setTimeout>;
|
||||
const reloadTimers = () =>
|
||||
timeoutSpy.mock.calls.flatMap((args, index) =>
|
||||
args[1] === 2000 ? [timeoutSpy.mock.results[index].value] : [],
|
||||
);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function setup(kind: 'export' | 'import' = 'import', tokenOnly = false) {
|
||||
let dispose!: () => void;
|
||||
const flow = createRoot((cleanup) => {
|
||||
dispose = cleanup;
|
||||
return useBackupTransferFlow({
|
||||
securityStatus: () =>
|
||||
({ hasAuthentication: !tokenOnly, apiTokenConfigured: tokenOnly }) as SecurityStatus,
|
||||
});
|
||||
});
|
||||
disposers.push(dispose);
|
||||
if (kind === 'import') flow.setShowImportDialog(true);
|
||||
else flow.setShowExportDialog(true);
|
||||
flow.setImportPassphrase(passphrase);
|
||||
flow.setExportPassphrase(passphrase);
|
||||
const read = vi.fn().mockResolvedValue('{"data":"synthetic-archive"}');
|
||||
flow.setImportFile({ text: read } as unknown as File);
|
||||
return { flow, read, dispose };
|
||||
}
|
||||
|
||||
const response = (status = 200, body: unknown = { status: 'success', data: 'synthetic-archive' }) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': 'replacement-csrf',
|
||||
'Retry-After': '1',
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
fetchMock.mockReset().mockResolvedValue(response());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
clearApiToken();
|
||||
setOrgID('tenant-a');
|
||||
document.cookie = 'pulse_csrf=synthetic-csrf; Path=/';
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
class extends URL {
|
||||
static createObjectURL = vi.fn(() => 'blob:synthetic');
|
||||
static revokeObjectURL = vi.fn();
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dispose of disposers.splice(0)) dispose();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
clearApiToken();
|
||||
setOrgID(null);
|
||||
});
|
||||
|
||||
describe('configuration transfers keep their confirmed workspace and one dispatch', () => {
|
||||
it.each(['export', 'import'] as const)(
|
||||
'does not send %s after the workspace changes before confirmation',
|
||||
async (kind) => {
|
||||
const { flow } = setup(kind);
|
||||
setOrgID('tenant-b');
|
||||
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(flow.transferOrgID()).toBe('tenant-a');
|
||||
expect(notificationStore.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Organisation changed'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not retarget a slow file read to the newly selected workspace', async () => {
|
||||
const { flow, read } = setup();
|
||||
const file = deferred<string>();
|
||||
read.mockReturnValue(file.promise);
|
||||
const pending = flow.handleImport();
|
||||
setOrgID('tenant-b');
|
||||
file.resolve('{"data":"synthetic-archive"}');
|
||||
await pending;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechecks the captured target after asynchronous CSRF preparation', async () => {
|
||||
const { flow } = setup();
|
||||
const csrf = deferred<string>();
|
||||
vi.spyOn(apiClient as any, 'loadCSRFToken').mockReturnValue(null);
|
||||
const prepare = vi.spyOn(apiClient as any, 'ensureCSRFToken').mockReturnValue(csrf.promise);
|
||||
const pending = flow.handleImport();
|
||||
await vi.waitFor(() => expect(prepare).toHaveBeenCalled());
|
||||
setOrgID('tenant-b');
|
||||
csrf.resolve('synthetic-csrf');
|
||||
await pending;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(flow.transferError()).toContain('Organisation changed');
|
||||
});
|
||||
|
||||
it.each(['export', 'import'] as const)(
|
||||
'does not replay %s on invalid_org, CSRF or rate-limit responses',
|
||||
async (kind) => {
|
||||
for (const status of [400, 403, 429]) {
|
||||
fetchMock.mockReset().mockResolvedValue(response(status, { error: 'invalid_org' }));
|
||||
const { flow } = setup(kind);
|
||||
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][1].headers['X-Pulse-Org-ID']).toBe('tenant-a');
|
||||
expect(getOrgID()).toBe('tenant-a');
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('takes one immutable file/passphrase snapshot and blocks double submission', async () => {
|
||||
const { flow, read } = setup();
|
||||
const file = deferred<string>();
|
||||
read.mockReturnValue(file.promise);
|
||||
const pending = flow.handleImport();
|
||||
flow.setImportPassphrase('edited-after-submit');
|
||||
flow.setImportFile({ text: vi.fn() } as unknown as File);
|
||||
await flow.handleImport();
|
||||
flow.closeImportDialog();
|
||||
expect(flow.showImportDialog()).toBe(true);
|
||||
expect(flow.isTransferring()).toBe(true);
|
||||
file.resolve('{"data":"synthetic-archive"}');
|
||||
await pending;
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ passphrase, data: 'synthetic-archive' });
|
||||
expect(options.headers['X-Pulse-Org-ID']).toBe('tenant-a');
|
||||
expect(options.headers['X-CSRF-Token']).toBe('synthetic-csrf');
|
||||
expect(options.credentials).toBe('include');
|
||||
expect(flow.isTransferring()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['export', 'import'] as const)(
|
||||
'does not replay %s when an API token is entered',
|
||||
async (kind) => {
|
||||
const { flow } = setup(kind, true);
|
||||
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
|
||||
expect(flow.showApiTokenModal()).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
flow.setApiTokenInput('synthetic-token');
|
||||
flow.handleApiTokenAuthenticate();
|
||||
expect(getApiToken()).toBe('synthetic-token');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await (kind === 'import' ? flow.handleImport() : flow.handleExport());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][1].headers['X-API-Token']).toBe('synthetic-token');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a token error visible and does not replay an uncertain import after token entry', async () => {
|
||||
const { flow } = setup();
|
||||
fetchMock.mockResolvedValue(response(403, { error: 'API token required' }));
|
||||
await flow.handleImport();
|
||||
expect(flow.showApiTokenModal()).toBe(true);
|
||||
expect(flow.transferError()).toContain('Check the destination before trying again');
|
||||
flow.setApiTokenInput('synthetic-token');
|
||||
flow.handleApiTokenAuthenticate();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(flow.transferError()).toContain('Import was not confirmed');
|
||||
});
|
||||
|
||||
it('does not save a prompted token in a different workspace', async () => {
|
||||
const { flow } = setup('import', true);
|
||||
await flow.handleImport();
|
||||
flow.setApiTokenInput('synthetic-token');
|
||||
setOrgID('tenant-b');
|
||||
flow.handleApiTokenAuthenticate();
|
||||
expect(getApiToken()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not download an export response into a different workspace', async () => {
|
||||
const { flow } = setup('export');
|
||||
const result = deferred<Response>();
|
||||
fetchMock.mockReturnValue(result.promise);
|
||||
const pending = flow.handleExport();
|
||||
setOrgID('tenant-b');
|
||||
result.resolve(response());
|
||||
await pending;
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
expect(flow.exportPassphrase()).toBe(passphrase);
|
||||
});
|
||||
|
||||
it('reports the original destination and never reloads a newly selected workspace after success', async () => {
|
||||
const { flow } = setup();
|
||||
const result = deferred<Response>();
|
||||
fetchMock.mockReturnValue(result.promise);
|
||||
const pending = flow.handleImport();
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
setOrgID('tenant-b');
|
||||
result.resolve(response());
|
||||
await pending;
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
expect(flow.transferError()).toContain('Import completed for workspace tenant-a');
|
||||
expect(reloadTimers()).toHaveLength(0);
|
||||
expect(flow.importPassphrase()).toBe('');
|
||||
expect(flow.importFile()).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['export', 'import'] as const)(
|
||||
'aborts %s and ignores a late response after unmount',
|
||||
async (kind) => {
|
||||
const { flow, dispose } = setup(kind);
|
||||
const result = deferred<Response>();
|
||||
fetchMock.mockReturnValue(result.promise);
|
||||
const pending = kind === 'import' ? flow.handleImport() : flow.handleExport();
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
dispose();
|
||||
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true);
|
||||
result.resolve(response());
|
||||
await pending;
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(reloadTimers()).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not dispatch a file read that finishes after unmount', async () => {
|
||||
const { flow, read, dispose } = setup();
|
||||
const file = deferred<string>();
|
||||
read.mockReturnValue(file.promise);
|
||||
const pending = flow.handleImport();
|
||||
dispose();
|
||||
file.resolve('{"data":"synthetic-archive"}');
|
||||
await pending;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"status":"success","data":"synthetic-archive"}',
|
||||
'{"data":"synthetic-archive"}',
|
||||
'"synthetic-archive"',
|
||||
'synthetic-archive\n',
|
||||
])('preserves supported archive envelope %s and explicit default scope', async (contents) => {
|
||||
setOrgID(null);
|
||||
const { flow, read } = setup();
|
||||
read.mockResolvedValue(contents);
|
||||
await flow.handleImport();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][1].headers['X-Pulse-Org-ID']).toBe('default');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
||||
passphrase,
|
||||
data: 'synthetic-archive',
|
||||
});
|
||||
expect(flow.showImportDialog()).toBe(false);
|
||||
expect(flow.importFile()).toBeNull();
|
||||
expect(flow.importPassphrase()).toBe('');
|
||||
});
|
||||
|
||||
it.each(['null', '{}', '{"data":5}', '{"data":""}', ' '])(
|
||||
'rejects invalid archive %s without network access',
|
||||
async (contents) => {
|
||||
const { flow, read } = setup();
|
||||
read.mockResolvedValue(contents);
|
||||
await flow.handleImport();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(flow.transferError()).toContain('Invalid backup file format');
|
||||
expect(flow.isTransferring()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('retains an uncertain response without success, reloading or a second request', async () => {
|
||||
const { flow } = setup();
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await flow.handleImport();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(flow.transferError()).toContain('Import was not confirmed');
|
||||
expect(flow.showImportDialog()).toBe(true);
|
||||
expect(reloadTimers()).toHaveLength(0);
|
||||
expect(notificationStore.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads the successful export once, releases the URL and clears the secret', async () => {
|
||||
const { flow } = setup('export');
|
||||
await flow.handleExport();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic');
|
||||
expect(flow.exportPassphrase()).toBe('');
|
||||
expect(flow.showExportDialog()).toBe(false);
|
||||
});
|
||||
|
||||
it('captures the new target only after the operator closes and reopens the dialog', () => {
|
||||
const { flow } = setup();
|
||||
flow.closeImportDialog();
|
||||
setOrgID('tenant-b');
|
||||
flow.setShowImportDialog(true);
|
||||
expect(flow.transferOrgID()).toBe('tenant-b');
|
||||
expect(flow.importFile()).toBeNull();
|
||||
expect(flow.importPassphrase()).toBe('');
|
||||
});
|
||||
|
||||
it.each(['leave', 'reopen'] as const)(
|
||||
'cancels the actual successful-import reload timer on %s',
|
||||
async (action) => {
|
||||
const { flow, dispose } = setup();
|
||||
await flow.handleImport();
|
||||
expect(reloadTimers()).toHaveLength(1);
|
||||
const cancel = vi.spyOn(globalThis, 'clearTimeout');
|
||||
if (action === 'leave') dispose();
|
||||
else flow.setShowExportDialog(true);
|
||||
expect(cancel).toHaveBeenCalledWith(reloadTimers()[0]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,237 +1,257 @@
|
||||
import { Accessor, createSignal } from 'solid-js';
|
||||
import { Accessor, createSignal, onCleanup } from 'solid-js';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
apiFetchJSON,
|
||||
getOrgID,
|
||||
getApiToken as getApiClientToken,
|
||||
setApiToken as setApiClientToken,
|
||||
} from '@/utils/apiClient';
|
||||
import type { SecurityStatus } from '@/types/config';
|
||||
|
||||
type ApiTokenModalSource = 'export' | 'import' | null;
|
||||
|
||||
interface UseBackupTransferFlowParams {
|
||||
securityStatus: Accessor<SecurityStatus | null>;
|
||||
}
|
||||
|
||||
const CHANGED_WORKSPACE =
|
||||
'Organisation changed. Close this dialog and reopen it in the intended workspace.';
|
||||
|
||||
export function useBackupTransferFlow({ securityStatus }: UseBackupTransferFlowParams) {
|
||||
const [exportPassphrase, setExportPassphrase] = createSignal('');
|
||||
const [useCustomPassphrase, setUseCustomPassphrase] = createSignal(false);
|
||||
const [importPassphrase, setImportPassphrase] = createSignal('');
|
||||
const [importFile, setImportFile] = createSignal<File | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = createSignal(false);
|
||||
const [showImportDialog, setShowImportDialog] = createSignal(false);
|
||||
const [showExportDialog, showExport] = createSignal(false);
|
||||
const [showImportDialog, showImport] = createSignal(false);
|
||||
const [showApiTokenModal, setShowApiTokenModal] = createSignal(false);
|
||||
const [apiTokenInput, setApiTokenInput] = createSignal('');
|
||||
const [apiTokenModalSource, setApiTokenModalSource] = createSignal<ApiTokenModalSource>(null);
|
||||
const [transferOrgID, setTransferOrgID] = createSignal('default');
|
||||
const [isTransferring, setIsTransferring] = createSignal(false);
|
||||
const [transferError, setTransferError] = createSignal('');
|
||||
let active = true;
|
||||
let controller: AbortController | undefined;
|
||||
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const closeExportDialog = () => {
|
||||
setShowExportDialog(false);
|
||||
setExportPassphrase('');
|
||||
setUseCustomPassphrase(false);
|
||||
onCleanup(() => {
|
||||
active = false;
|
||||
controller?.abort();
|
||||
clearTimeout(reloadTimer);
|
||||
});
|
||||
|
||||
const currentOrgID = () => getOrgID() || 'default';
|
||||
const fail = (message: string) => {
|
||||
setTransferError(message);
|
||||
notificationStore.error(message);
|
||||
};
|
||||
|
||||
const closeImportDialog = () => {
|
||||
setShowImportDialog(false);
|
||||
setImportPassphrase('');
|
||||
setImportFile(null);
|
||||
const checkWorkspace = () => {
|
||||
if (currentOrgID() === transferOrgID()) return true;
|
||||
fail(CHANGED_WORKSPACE);
|
||||
return false;
|
||||
};
|
||||
|
||||
const closeApiTokenModal = () => {
|
||||
setShowApiTokenModal(false);
|
||||
setApiTokenInput('');
|
||||
};
|
||||
const closeExportDialog = () => {
|
||||
if (isTransferring()) return;
|
||||
showExport(false);
|
||||
setExportPassphrase('');
|
||||
setUseCustomPassphrase(false);
|
||||
closeApiTokenModal();
|
||||
};
|
||||
const closeImportDialog = () => {
|
||||
if (isTransferring()) return;
|
||||
showImport(false);
|
||||
setImportPassphrase('');
|
||||
setImportFile(null);
|
||||
closeApiTokenModal();
|
||||
};
|
||||
const openDialog = (kind: 'export' | 'import') => {
|
||||
if (isTransferring()) return;
|
||||
clearTimeout(reloadTimer);
|
||||
closeExportDialog();
|
||||
closeImportDialog();
|
||||
setTransferError('');
|
||||
setTransferOrgID(currentOrgID());
|
||||
if (kind === 'export') showExport(true);
|
||||
else showImport(true);
|
||||
};
|
||||
const setShowExportDialog = (open: boolean) =>
|
||||
open ? openDialog('export') : closeExportDialog();
|
||||
const setShowImportDialog = (open: boolean) =>
|
||||
open ? openDialog('import') : closeImportDialog();
|
||||
|
||||
const needsApiToken = () => {
|
||||
if (
|
||||
!securityStatus()?.hasAuthentication &&
|
||||
securityStatus()?.apiTokenConfigured &&
|
||||
!getApiClientToken()
|
||||
) {
|
||||
setShowApiTokenModal(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!exportPassphrase()) {
|
||||
const hasAuth = securityStatus()?.hasAuthentication;
|
||||
notificationStore.error(
|
||||
hasAuth
|
||||
? useCustomPassphrase()
|
||||
? 'Please enter a passphrase'
|
||||
: 'Please enter your password'
|
||||
if (!active || !showExportDialog() || isTransferring() || !checkWorkspace()) return;
|
||||
setTransferError('');
|
||||
const passphrase = exportPassphrase();
|
||||
if (!passphrase) {
|
||||
fail(
|
||||
securityStatus()?.hasAuthentication && !useCustomPassphrase()
|
||||
? 'Please enter your password'
|
||||
: 'Please enter a passphrase',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend requires at least 12 characters for encryption security
|
||||
if (exportPassphrase().length < 12) {
|
||||
const hasAuth = securityStatus()?.hasAuthentication;
|
||||
notificationStore.error(
|
||||
hasAuth && !useCustomPassphrase()
|
||||
if (passphrase.length < 12) {
|
||||
fail(
|
||||
securityStatus()?.hasAuthentication && !useCustomPassphrase()
|
||||
? 'Your password must be at least 12 characters. Please use a custom passphrase instead.'
|
||||
: 'Passphrase must be at least 12 characters long',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (needsApiToken()) return;
|
||||
|
||||
// Only check for API token if user is not authenticated via password
|
||||
// If user is logged in with password, session auth is sufficient
|
||||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) {
|
||||
setApiTokenModalSource('export');
|
||||
setShowApiTokenModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const orgID = transferOrgID();
|
||||
controller = new AbortController();
|
||||
setIsTransferring(true);
|
||||
try {
|
||||
// Get CSRF token from cookie
|
||||
const csrfCookie = document.cookie.split('; ').find((row) => row.startsWith('pulse_csrf='));
|
||||
const csrfToken = csrfCookie
|
||||
? decodeURIComponent(csrfCookie.split('=').slice(1).join('='))
|
||||
: undefined;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add CSRF token if available
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
// Add API token if configured
|
||||
const apiToken = getApiClientToken();
|
||||
if (apiToken) {
|
||||
headers['X-API-Token'] = apiToken;
|
||||
}
|
||||
|
||||
const data = await apiFetchJSON<any>('/api/config/export', {
|
||||
const data = await apiFetchJSON('/api/config/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ passphrase: exportPassphrase() }),
|
||||
body: JSON.stringify({ passphrase }),
|
||||
expectedOrgID: orgID,
|
||||
retry: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!active || !checkWorkspace()) return;
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
try {
|
||||
a.href = url;
|
||||
a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
} finally {
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
notificationStore.success('Configuration exported successfully');
|
||||
setShowExportDialog(false);
|
||||
showExport(false);
|
||||
setExportPassphrase('');
|
||||
setUseCustomPassphrase(false);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to export configuration';
|
||||
notificationStore.error(errorMessage);
|
||||
logger.error('Export error', error);
|
||||
if (active) fail(error instanceof Error ? error.message : 'Failed to export configuration');
|
||||
} finally {
|
||||
if (active) setIsTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importPassphrase()) {
|
||||
notificationStore.error('Please enter the password');
|
||||
if (!active || !showImportDialog() || isTransferring() || !checkWorkspace()) return;
|
||||
setTransferError('');
|
||||
const passphrase = importPassphrase();
|
||||
const file = importFile();
|
||||
if (!passphrase) {
|
||||
fail('Please enter the password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!importFile()) {
|
||||
notificationStore.error('Please select a file to import');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check for API token if user is not authenticated via password
|
||||
// If user is logged in with password, session auth is sufficient
|
||||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) {
|
||||
setApiTokenModalSource('import');
|
||||
setShowApiTokenModal(true);
|
||||
if (!file) {
|
||||
fail('Please select a file to import');
|
||||
return;
|
||||
}
|
||||
if (needsApiToken()) return;
|
||||
|
||||
// Freeze the confirmed target and inputs before the asynchronous file read.
|
||||
const orgID = transferOrgID();
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
setIsTransferring(true);
|
||||
let submitted = false;
|
||||
try {
|
||||
const fileContent = await importFile()!.text();
|
||||
const fileContent = await file.text();
|
||||
if (!active || !checkWorkspace()) return;
|
||||
|
||||
// Support three formats:
|
||||
// 1. UI export: {status: "success", data: "base64string"}
|
||||
// 2. Legacy format: {data: "base64string"}
|
||||
// 3. CLI export: raw base64 string (no JSON wrapper)
|
||||
let encryptedData: string;
|
||||
|
||||
// Try to parse as JSON first
|
||||
// UI/legacy JSON envelopes, JSON strings and raw CLI base64 remain supported.
|
||||
let encryptedData: unknown;
|
||||
try {
|
||||
const exportData = JSON.parse(fileContent);
|
||||
|
||||
if (typeof exportData === 'string') {
|
||||
// Raw base64 string wrapped in JSON (edge case)
|
||||
encryptedData = exportData;
|
||||
} else if (exportData.data) {
|
||||
// Standard format with data field
|
||||
encryptedData = exportData.data;
|
||||
} else {
|
||||
notificationStore.error(
|
||||
'Invalid backup file format. Expected encrypted data in "data" field.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_parseError) {
|
||||
// Not JSON - treat entire contents as raw base64 from CLI export
|
||||
const parsed: unknown = JSON.parse(fileContent);
|
||||
encryptedData =
|
||||
typeof parsed === 'string'
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object' && 'data' in parsed
|
||||
? parsed.data
|
||||
: undefined;
|
||||
} catch {
|
||||
encryptedData = fileContent.trim();
|
||||
}
|
||||
|
||||
await apiFetchJSON('/api/config/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
passphrase: importPassphrase(),
|
||||
data: encryptedData,
|
||||
}),
|
||||
});
|
||||
|
||||
notificationStore.success('Configuration imported successfully. Reloading...');
|
||||
setShowImportDialog(false);
|
||||
setImportPassphrase('');
|
||||
setImportFile(null);
|
||||
|
||||
// Reload page to apply new configuration
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
const errorText = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Handle specific error cases if possible, though apiFetch usually handles 401/403
|
||||
// But for Import, we might want to trigger the token modal if it was a token issue
|
||||
// Note: apiFetch throws Error with message.
|
||||
|
||||
const normalizedError = errorText.toLowerCase();
|
||||
if (
|
||||
errorText.includes('API_TOKEN') ||
|
||||
errorText.includes('API_TOKENS') ||
|
||||
normalizedError.includes('api token') ||
|
||||
normalizedError.includes('x-api-token')
|
||||
) {
|
||||
setApiTokenModalSource('import');
|
||||
setShowApiTokenModal(true);
|
||||
if (typeof encryptedData !== 'string' || !encryptedData.trim()) {
|
||||
fail('Invalid backup file format. Expected encrypted data in "data" field.');
|
||||
return;
|
||||
}
|
||||
|
||||
notificationStore.error(errorText || 'Failed to import configuration');
|
||||
logger.error('Import error', error);
|
||||
submitted = true;
|
||||
await apiFetchJSON('/api/config/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ passphrase, data: encryptedData }),
|
||||
expectedOrgID: orgID,
|
||||
retry: false,
|
||||
signal,
|
||||
});
|
||||
if (!active) return;
|
||||
if (currentOrgID() !== orgID) {
|
||||
fail(
|
||||
`Import completed for workspace ${orgID}. The selected workspace changed; no reload was performed.`,
|
||||
);
|
||||
showImport(false);
|
||||
setImportPassphrase('');
|
||||
setImportFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
notificationStore.success('Configuration imported successfully. Reloading...');
|
||||
showImport(false);
|
||||
setImportPassphrase('');
|
||||
setImportFile(null);
|
||||
// Never reload a different workspace or an unmounted Settings view.
|
||||
reloadTimer = setTimeout(() => {
|
||||
if (active && currentOrgID() === orgID) window.location.reload();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
const errorText = error instanceof Error ? error.message : 'Failed to import configuration';
|
||||
fail(
|
||||
submitted
|
||||
? `${errorText} Import was not confirmed and may have applied. Check the destination before trying again.`
|
||||
: errorText,
|
||||
);
|
||||
if (/api[_ -]?tokens?|x-api-token/i.test(errorText)) setShowApiTokenModal(true);
|
||||
} finally {
|
||||
if (active) setIsTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiTokenAuthenticate = () => {
|
||||
if (!apiTokenInput()) {
|
||||
notificationStore.error('Please enter the API token');
|
||||
if (!active || isTransferring() || !checkWorkspace()) {
|
||||
closeApiTokenModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenValue = apiTokenInput()!;
|
||||
setApiClientToken(tokenValue);
|
||||
const source = apiTokenModalSource();
|
||||
setShowApiTokenModal(false);
|
||||
setApiTokenInput('');
|
||||
setApiTokenModalSource(null);
|
||||
|
||||
// Retry the operation that triggered the modal
|
||||
if (source === 'export') {
|
||||
void handleExport();
|
||||
} else if (source === 'import') {
|
||||
void handleImport();
|
||||
if (!apiTokenInput().trim()) {
|
||||
fail('Please enter the API token');
|
||||
return;
|
||||
}
|
||||
setApiClientToken(apiTokenInput());
|
||||
if (!getApiClientToken()) {
|
||||
fail('Please enter a valid API token');
|
||||
return;
|
||||
}
|
||||
closeApiTokenModal();
|
||||
// Saving a credential is not consent to replay a possibly applied import.
|
||||
notificationStore.info('API token saved. Review the workspace and submit the transfer again.');
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -250,6 +270,9 @@ export function useBackupTransferFlow({ securityStatus }: UseBackupTransferFlowP
|
||||
showApiTokenModal,
|
||||
apiTokenInput,
|
||||
setApiTokenInput,
|
||||
transferOrgID,
|
||||
isTransferring,
|
||||
transferError,
|
||||
handleExport,
|
||||
handleImport,
|
||||
closeExportDialog,
|
||||
|
||||
Reference in New Issue
Block a user