Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ src-tauri/gen/
# Local security scan artifacts
.secrets.baseline
.secretscan-venv/
coverage/
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
2,161 changes: 711 additions & 1,450 deletions package-lock.json

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,30 @@
"tauri": "cargo tauri"
},
"dependencies": {
"@fluentui/react-components": "^9.72.11",
"@fluentui/react-icons": "^2.0.318",
"@tanstack/react-query": "^5.90.20",
"@fluentui/react-components": "^9.73.0",
"@fluentui/react-icons": "^2.0.319",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-store": "^2.4.2",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.39.1",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"typescript-eslint": "^8.56.0",
"vite": "^7.3.1",
"vitest": "^3.2.4"
"vitest": "^4.0.18"
}
}
89 changes: 70 additions & 19 deletions src/components/secrets/SecretsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
toggleSelection,
toggleSelectionAll,
} from './secretsBulkDeleteLogic';
import { exportSecretMetadata, type ExportFormat } from './secretsExport';
import type { Column } from '../common/ItemTable';
import type { SecretItem } from '../../types';

Expand Down Expand Up @@ -118,6 +119,8 @@ export function SecretsList() {
const [bulkDeleteLoading, setBulkDeleteLoading] = useState(false);
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
const [deleteConfirmInput, setDeleteConfirmInput] = useState('');
const [exportMessage, setExportMessage] = useState<string | null>(null);
const [exportMessageTone, setExportMessageTone] = useState<'success' | 'error'>('success');
const [bulkDeleteProgress, setBulkDeleteProgress] = useState({
total: 0,
completed: 0,
Expand All @@ -132,7 +135,7 @@ export function SecretsList() {

// ── Derived / filtered data ──

const allSecrets = secretsQuery.data || [];
const allSecrets = useMemo(() => secretsQuery.data ?? [], [secretsQuery.data]);
const filteredSecrets = allSecrets.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
Expand Down Expand Up @@ -182,32 +185,55 @@ export function SecretsList() {
}
}, [showBulkDeleteConfirm]);

useEffect(() => {
if (!exportMessage) return;
const timer = window.setTimeout(() => setExportMessage(null), 3000);
return () => window.clearTimeout(timer);
}, [exportMessage]);

// ── Handlers ──

const downloadExport = (content: string, format: ExportFormat) => {
const mimeType = format === 'json' ? 'application/json' : 'text/csv;charset=utf-8';
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `azvault-secrets-${Date.now()}.${format}`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
Comment on lines +196 to +208

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The downloadExport function is defined inside the SecretsList component, which causes it to be recreated on every render. Since this function does not depend on any component props or state, it can be moved outside the component's scope. This is a React best practice that improves performance by avoiding unnecessary function re-creations and makes the component's rendering logic cleaner.


const handleSelect = (item: SecretItem) => {
setSelectedSecret(item);
setDrawerOpen(true);
};

/** Export metadata (never secret values) as JSON or CSV. */
const handleExport = async (format: 'json' | 'csv') => {
const metadata = filteredSecrets.map(
({ name, enabled, created, updated, expires, contentType, tags }) => ({
name,
enabled,
created,
updated,
expires,
contentType,
tags: tags ? JSON.stringify(tags) : '',
}),
);
try {
const result = await exportItems(JSON.stringify(metadata), format);
await navigator.clipboard.writeText(result);
} catch {
// Export errors are non-critical – silently ignored
}
const handleExport = async (format: ExportFormat) => {
await exportSecretMetadata(filteredSecrets, format, {
exportItems,
download: downloadExport,
writeClipboard: navigator.clipboard?.writeText
? (content) => navigator.clipboard.writeText(content)
: undefined,
onError: (error) => {
setExportMessageTone('error');
setExportMessage('Export failed.');
console.error('Export failed:', error);
},
onSuccess: (mode) => {
setExportMessageTone('success');
setExportMessage(
mode === 'download'
? `${format.toUpperCase()} downloaded.`
: `${format.toUpperCase()} copied to clipboard.`,
);
},
});
};

const toggleSelect = (id: string, checked: boolean) => {
Expand Down Expand Up @@ -337,6 +363,31 @@ export function SecretsList() {
</Button>
</div>
</div>
{exportMessage && (
<div
style={{
padding: '6px 16px',
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
background:
exportMessageTone === 'success'
? tokens.colorPaletteGreenBackground1
: tokens.colorPaletteRedBackground1,
}}
>
<Text
size={200}
className="azv-mono"
style={{
color:
exportMessageTone === 'success'
? tokens.colorPaletteGreenForeground1
: tokens.colorPaletteRedForeground1,
}}
>
{exportMessage}
</Text>
</div>
)}

{/* Table */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px' }}>
Expand Down
149 changes: 149 additions & 0 deletions src/components/secrets/secretsExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest';
import type { SecretItem } from '../../types';
import { buildSecretMetadata, exportSecretMetadata } from './secretsExport';

function makeSecret(overrides?: Partial<SecretItem>): SecretItem {
return {
id: 'id-1',
name: 'secret-a',
enabled: true,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-02T00:00:00Z',
expires: null,
notBefore: null,
contentType: 'text/plain',
tags: { env: 'dev' },
managed: null,
...overrides,
};
}

describe('secretsExport', () => {
it('builds metadata rows without secret values', () => {
const out = buildSecretMetadata([
makeSecret(),
makeSecret({ name: 'secret-b', tags: null, contentType: null }),
]);

expect(out).toEqual([
{
name: 'secret-a',
enabled: true,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-02T00:00:00Z',
expires: null,
contentType: 'text/plain',
tags: '{"env":"dev"}',
},
{
name: 'secret-b',
enabled: true,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-02T00:00:00Z',
expires: null,
contentType: null,
tags: '',
},
]);
});

it('exports and downloads when primary path succeeds', async () => {
const exportItems = vi.fn<(...args: [string, 'json' | 'csv']) => Promise<string>>();
exportItems.mockResolvedValue('payload-json');
const download = vi.fn<(content: string, format: 'json' | 'csv') => void>();
const writeClipboard = vi.fn<(content: string) => Promise<void>>();
writeClipboard.mockResolvedValue();
const onError = vi.fn<(error: unknown) => void>();
const onSuccess = vi.fn<(mode: 'download' | 'clipboard') => void>();

await exportSecretMetadata([makeSecret()], 'json', {
exportItems,
download,
writeClipboard,
onError,
onSuccess,
});

expect(exportItems).toHaveBeenCalledTimes(1);
expect(exportItems).toHaveBeenCalledWith(
JSON.stringify([
{
name: 'secret-a',
enabled: true,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-02T00:00:00Z',
expires: null,
contentType: 'text/plain',
tags: '{"env":"dev"}',
},
]),
'json',
);
expect(download).toHaveBeenCalledWith('payload-json', 'json');
expect(writeClipboard).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
expect(onSuccess).toHaveBeenCalledWith('download');
});

it('falls back to clipboard when download fails', async () => {
const exportItems = vi.fn<(...args: [string, 'json' | 'csv']) => Promise<string>>();
exportItems.mockResolvedValue('payload-csv');
const download = vi.fn<(content: string, format: 'json' | 'csv') => void>();
download.mockImplementation(() => {
throw new Error('download blocked');
});
const writeClipboard = vi.fn<(content: string) => Promise<void>>();
writeClipboard.mockResolvedValue();
const onError = vi.fn<(error: unknown) => void>();
const onSuccess = vi.fn<(mode: 'download' | 'clipboard') => void>();

await exportSecretMetadata([makeSecret()], 'csv', {
exportItems,
download,
writeClipboard,
onError,
onSuccess,
});

expect(download).toHaveBeenCalledWith('payload-csv', 'csv');
expect(writeClipboard).toHaveBeenCalledWith('payload-csv');
expect(onError).not.toHaveBeenCalled();
expect(onSuccess).toHaveBeenCalledWith('clipboard');
});

it('reports error when both download and clipboard are unavailable', async () => {
const exportItems = vi.fn<(...args: [string, 'json' | 'csv']) => Promise<string>>();
exportItems.mockResolvedValue('payload-json');
const download = vi.fn<(content: string, format: 'json' | 'csv') => void>();
download.mockImplementation(() => {
throw new Error('download blocked');
});
const onError = vi.fn<(error: unknown) => void>();

await exportSecretMetadata([makeSecret()], 'json', {
exportItems,
download,
onError,
});

expect(onError).toHaveBeenCalledTimes(1);
expect(String(onError.mock.calls[0][0])).toContain('Unable to download or copy export.');
});

it('reports backend export errors', async () => {
const exportItems = vi.fn<(...args: [string, 'json' | 'csv']) => Promise<string>>();
exportItems.mockRejectedValue(new Error('backend failed'));
const download = vi.fn<(content: string, format: 'json' | 'csv') => void>();
const onError = vi.fn<(error: unknown) => void>();

await exportSecretMetadata([makeSecret()], 'json', {
exportItems,
download,
onError,
});

expect(download).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
expect(String(onError.mock.calls[0][0])).toContain('backend failed');
});
});
63 changes: 63 additions & 0 deletions src/components/secrets/secretsExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { SecretItem } from '../../types';

export type ExportFormat = 'json' | 'csv';

type ExportItemsFn = (itemsJson: string, format: ExportFormat) => Promise<string>;
type DownloadFn = (content: string, format: ExportFormat) => void;
type ClipboardFn = (content: string) => Promise<void>;
type ErrorFn = (error: unknown) => void;
type SuccessFn = (mode: 'download' | 'clipboard') => void;

export type SecretExportMetadata = {
name: string;
enabled: boolean;
created: string | null;
updated: string | null;
expires: string | null;
contentType: string | null;
tags: string;
};

export function buildSecretMetadata(items: SecretItem[]): SecretExportMetadata[] {
return items.map(({ name, enabled, created, updated, expires, contentType, tags }) => ({
name,
enabled,
created,
updated,
expires,
contentType,
tags: tags ? JSON.stringify(tags) : '',
}));
}

export async function exportSecretMetadata(
items: SecretItem[],
format: ExportFormat,
deps: {
exportItems: ExportItemsFn;
download: DownloadFn;
writeClipboard?: ClipboardFn;
onError?: ErrorFn;
onSuccess?: SuccessFn;
},
): Promise<void> {
const { exportItems, download, writeClipboard, onError, onSuccess } = deps;

try {
const metadata = buildSecretMetadata(items);
const result = await exportItems(JSON.stringify(metadata), format);

try {
download(result, format);
onSuccess?.('download');
} catch {
if (!writeClipboard) {
throw new Error('Unable to download or copy export.');
}
await writeClipboard(result);
onSuccess?.('clipboard');
}
} catch (error) {
onError?.(error);
}
}
Loading