diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e7c7b3..656860f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: - branches: ["**"] + branches: ["main"] pull_request: + branches: ["main"] jobs: secrets: diff --git a/README.md b/README.md index 2f7f4fb..4e69e84 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - Browse Secrets, Keys, Certificates - Secret metadata + explicit value fetch flow - Secret CRUD lifecycle (set/delete/recover/purge) +- Import secrets from JSON file - Bulk delete safety flow: - typed confirmation (`delete`) - collapsible list of selected secrets @@ -118,6 +119,46 @@ Optional mock mode for UI development: VITE_ENABLE_MOCK_MODE=true npm run dev ``` +## Import Secrets from JSON + +Use the **Import JSON** button in the Secrets toolbar, or run **Import Secrets from JSON** from the command palette. + +Accepted formats: + +```json +[ + { + "name": "my-secret", + "value": "secret-value", + "contentType": "text/plain", + "enabled": true, + "expires": "2030-01-01T00:00:00Z", + "notBefore": "2026-01-01T00:00:00Z", + "tags": { + "env": "prod" + } + } +] +``` + +or: + +```json +{ + "secrets": [ + { "name": "my-secret", "value": "secret-value" } + ] +} +``` + +Rules: +- `name` and `value` are required. +- `name` supports letters, numbers, and dashes only. +- `tags` must be an object with string values. +- `expires` and `notBefore` must be valid date strings. + +Sample file: [`examples/secrets-import.example.json`](./examples/secrets-import.example.json) + ## Quality Gates Frontend: diff --git a/examples/secrets-import.example.json b/examples/secrets-import.example.json new file mode 100644 index 0000000..2b66b06 --- /dev/null +++ b/examples/secrets-import.example.json @@ -0,0 +1,21 @@ +{ + "secrets": [ + { + "name": "api-key-prod", + "value": "replace-me", + "contentType": "text/plain", + "enabled": true, + "tags": { + "env": "prod", + "team": "platform" + } + }, + { + "name": "app-config-json", + "value": "{\"featureFlag\":true}", + "contentType": "application/json", + "expires": "2030-01-01T00:00:00Z", + "notBefore": "2026-01-01T00:00:00Z" + } + ] +} diff --git a/src/components/auth/SignIn.tsx b/src/components/auth/SignIn.tsx index eb7e2b2..16fac73 100644 --- a/src/components/auth/SignIn.tsx +++ b/src/components/auth/SignIn.tsx @@ -4,10 +4,10 @@ import { Card, CardFooter, CardHeader, + makeStyles, Spinner, Text, Tooltip, - makeStyles, tokens, } from '@fluentui/react-components'; import { @@ -243,9 +243,7 @@ export function SignIn() {
- } + image={} header={
@@ -338,9 +336,7 @@ export function SignIn() { />
-

- Then click Connect below to verify the session. -

+

Then click Connect below to verify the session.

{/* Error */} diff --git a/src/components/certificates/CertificateDetails.tsx b/src/components/certificates/CertificateDetails.tsx index ee5874d..623010f 100644 --- a/src/components/certificates/CertificateDetails.tsx +++ b/src/components/certificates/CertificateDetails.tsx @@ -2,9 +2,9 @@ import { Badge, Button, Field, - Text, makeStyles, mergeClasses, + Text, tokens, } from '@fluentui/react-components'; import { Certificate24Regular, Copy24Regular, Dismiss24Regular } from '@fluentui/react-icons'; diff --git a/src/components/certificates/CertificatesList.tsx b/src/components/certificates/CertificatesList.tsx index c2b4d82..01b7526 100644 --- a/src/components/certificates/CertificatesList.tsx +++ b/src/components/certificates/CertificatesList.tsx @@ -1,11 +1,4 @@ -import { - Button, - Input, - Text, - makeStyles, - mergeClasses, - tokens, -} from '@fluentui/react-components'; +import { Button, Input, makeStyles, mergeClasses, Text, tokens } from '@fluentui/react-components'; import { Search24Regular } from '@fluentui/react-icons'; import { useQuery } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; @@ -161,10 +154,7 @@ export function CertificatesList() { ); const expired = new Date(item.expires) < new Date(); return ( - + {renderDate(item.expires)} ); diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index b26d5e3..6fb51e1 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -1,4 +1,4 @@ -import { Input, Text, makeStyles, tokens } from '@fluentui/react-components'; +import { Input, makeStyles, Text, tokens } from '@fluentui/react-components'; import { Search24Regular } from '@fluentui/react-icons'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppStore } from '../../stores/appStore'; @@ -133,11 +133,7 @@ export function CommandPalette() { onClick={() => execute(result.item)} onMouseEnter={() => setActiveIndex(i)} > - {result.item.icon && ( - - {result.item.icon} - - )} + {result.item.icon && {result.item.icon}} {result.item.label} @@ -245,6 +241,13 @@ function useCommands(): PaletteCommand[] { execute: () => window.dispatchEvent(new CustomEvent('azv:new-secret')), when: () => !!store.selectedVaultName, }, + { + id: 'import-secrets-json', + label: 'Import Secrets from JSON', + category: 'action', + execute: () => window.dispatchEvent(new CustomEvent('azv:import-secrets')), + when: () => !!store.selectedVaultName, + }, { id: 'select-all', label: 'Select All Items', diff --git a/src/components/common/DangerConfirmDialog.tsx b/src/components/common/DangerConfirmDialog.tsx index 3ea2771..399a0a7 100644 --- a/src/components/common/DangerConfirmDialog.tsx +++ b/src/components/common/DangerConfirmDialog.tsx @@ -7,9 +7,9 @@ import { DialogSurface, DialogTitle, Input, + makeStyles, Spinner, Text, - makeStyles, tokens, } from '@fluentui/react-components'; import { Warning24Regular } from '@fluentui/react-icons'; @@ -99,9 +99,7 @@ export function DangerConfirmDialog({ {isCritical && ( -
- This action is irreversible. -
+
This action is irreversible.
)} diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx index 482cf82..9a8f7d9 100644 --- a/src/components/common/EmptyState.tsx +++ b/src/components/common/EmptyState.tsx @@ -1,4 +1,4 @@ -import { Button, Text, makeStyles, tokens } from '@fluentui/react-components'; +import { Button, makeStyles, Text, tokens } from '@fluentui/react-components'; interface EmptyStateProps { icon?: React.ReactNode; @@ -37,7 +37,12 @@ export function EmptyState({ icon, title, description, action }: EmptyStateProps )} {action && ( - )} diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx index 3d272bb..92b3ed5 100644 --- a/src/components/common/ErrorMessage.tsx +++ b/src/components/common/ErrorMessage.tsx @@ -1,4 +1,4 @@ -import { Button, Text, makeStyles, tokens } from '@fluentui/react-components'; +import { Button, makeStyles, Text, tokens } from '@fluentui/react-components'; import { DismissCircle24Regular } from '@fluentui/react-icons'; import type { UserFacingError } from '../../types'; diff --git a/src/components/common/ItemTable.tsx b/src/components/common/ItemTable.tsx index 360277e..d539b35 100644 --- a/src/components/common/ItemTable.tsx +++ b/src/components/common/ItemTable.tsx @@ -2,6 +2,7 @@ import { Badge, Checkbox, + makeStyles, Spinner, Table, TableBody, @@ -9,7 +10,6 @@ import { TableHeader, TableRow, Text, - makeStyles, tokens, } from '@fluentui/react-components'; import { format } from 'date-fns'; @@ -117,7 +117,9 @@ export function ItemTable({ /> )} - # + + # + {columns.map((col) => ( {col.label} @@ -210,7 +212,13 @@ export function renderTags(tags: Record | null) { {Object.entries(tags) .slice(0, 3) .map(([k, v]) => ( - + {k}={v} diff --git a/src/components/keys/KeyDetails.tsx b/src/components/keys/KeyDetails.tsx index af2a01f..8cd36eb 100644 --- a/src/components/keys/KeyDetails.tsx +++ b/src/components/keys/KeyDetails.tsx @@ -3,8 +3,8 @@ import { Button, Divider, Field, - Text, makeStyles, + Text, tokens, } from '@fluentui/react-components'; import { Dismiss24Regular, LockClosed24Regular } from '@fluentui/react-icons'; @@ -206,11 +206,7 @@ function MetaField({ label, value, mono }: { label: string; value: string; mono? const classes = useStyles(); return ( - + {value} diff --git a/src/components/keys/KeysList.tsx b/src/components/keys/KeysList.tsx index b1b40c7..d25e381 100644 --- a/src/components/keys/KeysList.tsx +++ b/src/components/keys/KeysList.tsx @@ -1,11 +1,4 @@ -import { - Badge, - Button, - Input, - Text, - makeStyles, - tokens, -} from '@fluentui/react-components'; +import { Badge, Button, Input, makeStyles, Text, tokens } from '@fluentui/react-components'; import { Search24Regular } from '@fluentui/react-icons'; import { useQuery } from '@tanstack/react-query'; import { useState } from 'react'; @@ -92,10 +85,7 @@ function ExpiresCell({ item }: { item: KeyItem }) { ); const expired = new Date(item.expires) < new Date(); return ( - + {renderDate(item.expires)} ); diff --git a/src/components/layout/ContentTabs.tsx b/src/components/layout/ContentTabs.tsx index 854ad3f..58ad390 100644 --- a/src/components/layout/ContentTabs.tsx +++ b/src/components/layout/ContentTabs.tsx @@ -1,4 +1,4 @@ -import { Tab, TabList, makeStyles, tokens } from '@fluentui/react-components'; +import { makeStyles, Tab, TabList, tokens } from '@fluentui/react-components'; import { Certificate24Regular, ClipboardTextLtr24Regular, diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4ddbed8..24f4cce 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,12 @@ -import { Badge, Button, Text, Tooltip, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'; +import { + Badge, + Button, + makeStyles, + mergeClasses, + Text, + Tooltip, + tokens, +} from '@fluentui/react-components'; import { Certificate24Regular, ClipboardTextLtr24Regular, @@ -25,10 +33,18 @@ interface NavItem { const navIconStyle = { fontSize: 16 } as const; const VAULT_NAV: NavItem[] = [ - { id: 'dashboard', label: 'Dashboard', icon: }, + { + id: 'dashboard', + label: 'Dashboard', + icon: , + }, { id: 'secrets', label: 'Secrets', icon: }, { id: 'keys', label: 'Keys', icon: }, - { id: 'certificates', label: 'Certificates', icon: }, + { + id: 'certificates', + label: 'Certificates', + icon: , + }, { id: 'logs', label: 'Audit Log', icon: }, ]; diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index c9561ab..e36a9cf 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,4 +1,4 @@ -import { Badge, Text, makeStyles, tokens } from '@fluentui/react-components'; +import { Badge, makeStyles, Text, tokens } from '@fluentui/react-components'; import { useAppStore } from '../../stores/appStore'; const useStyles = makeStyles({ diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index a24ff81..eb0d3ec 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -7,8 +7,8 @@ import { MenuList, MenuPopover, MenuTrigger, - Text, makeStyles, + Text, tokens, } from '@fluentui/react-components'; import { @@ -120,7 +120,11 @@ export function TopBar() { - diff --git a/src/components/secrets/SecretDetails.tsx b/src/components/secrets/SecretDetails.tsx index 1792e8c..bec5fe2 100644 --- a/src/components/secrets/SecretDetails.tsx +++ b/src/components/secrets/SecretDetails.tsx @@ -382,11 +382,7 @@ function MetaField({ label, value, mono }: { label: string; value: string; mono? const classes = useStyles(); return ( - + {value} diff --git a/src/components/secrets/SecretsList.tsx b/src/components/secrets/SecretsList.tsx index ee0a838..2df0d3b 100644 --- a/src/components/secrets/SecretsList.tsx +++ b/src/components/secrets/SecretsList.tsx @@ -1,14 +1,21 @@ import { Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, Input, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger, - Text, makeStyles, mergeClasses, + Spinner, + Text, tokens, } from '@fluentui/react-components'; import { @@ -18,8 +25,8 @@ import { Search24Regular, } from '@fluentui/react-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; -import { deleteSecret, exportItems, listSecrets } from '../../services/tauri'; +import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { deleteSecret, exportItems, listSecrets, setSecret } from '../../services/tauri'; import { useAppStore } from '../../stores/appStore'; import type { SecretItem } from '../../types'; import { DangerConfirmDialog } from '../common/DangerConfirmDialog'; @@ -42,6 +49,7 @@ import { toggleSelectionAll, } from './secretsBulkDeleteLogic'; import { type ExportFormat, exportSecretMetadata } from './secretsExport'; +import { parseSecretsImportJson } from './secretsImport'; const useStyles = makeStyles({ listRoot: { @@ -147,8 +155,60 @@ const useStyles = makeStyles({ textExpires: { fontSize: '11px', }, + importConfirmMeta: { + display: 'grid', + gridTemplateColumns: '160px 1fr', + gap: '6px 10px', + marginTop: '8px', + marginBottom: '10px', + fontSize: '12px', + }, + importConfirmLabel: { + color: tokens.colorNeutralForeground3, + }, + importConfirmValue: { + fontFamily: "'IBM Plex Mono', monospace", + wordBreak: 'break-word', + }, + importConfirmWarning: { + marginTop: '8px', + marginBottom: '8px', + padding: '8px 10px', + borderRadius: '6px', + background: tokens.colorPaletteYellowBackground1, + color: tokens.colorPaletteYellowForeground1, + fontSize: '12px', + }, + importConfirmListWrap: { + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: '6px', + padding: '8px 10px', + marginTop: '10px', + }, + importConfirmList: { + marginTop: '6px', + maxHeight: '180px', + overflow: 'auto', + }, + importConfirmItem: { + padding: '2px 0', + }, }); +interface PendingImport { + fileName: string; + fileSizeBytes: number; + requests: ReturnType['requests']; + duplicateNamesInFile: string[]; + existingSecretNames: string[]; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function SecretsList() { const classes = useStyles(); const { selectedVaultUri, searchQuery, detailPanelOpen, splitRatio, setSplitRatio } = @@ -170,6 +230,12 @@ export function SecretsList() { const [showPrefixDeleteDialog, setShowPrefixDeleteDialog] = useState(false); const [exportMessage, setExportMessage] = useState(null); const [exportMessageTone, setExportMessageTone] = useState<'success' | 'error'>('success'); + const [importMessage, setImportMessage] = useState(null); + const [importMessageTone, setImportMessageTone] = useState<'success' | 'error'>('success'); + const [importLoading, setImportLoading] = useState(false); + const [showImportConfirm, setShowImportConfirm] = useState(false); + const [pendingImport, setPendingImport] = useState(null); + const importInputRef = useRef(null); const columns: Column[] = useMemo( () => [ @@ -194,11 +260,7 @@ export function SecretsList() { label: 'Type', width: '15%', render: (item) => ( - + {item.contentType || '—'} ), @@ -298,6 +360,12 @@ export function SecretsList() { return () => window.clearTimeout(timer); }, [exportMessage]); + useEffect(() => { + if (!importMessage) return; + const timer = window.setTimeout(() => setImportMessage(null), 5000); + return () => window.clearTimeout(timer); + }, [importMessage]); + // Listen for custom events from command palette useEffect(() => { const onNewSecret = () => setCreateOpen(true); @@ -306,16 +374,121 @@ export function SecretsList() { input?.focus(); }; const onDeleteByPrefix = () => setShowPrefixDeleteDialog(true); + const onImportSecrets = () => { + if (!importInputRef.current) return; + importInputRef.current.value = ''; + importInputRef.current.click(); + }; window.addEventListener('azv:new-secret', onNewSecret); window.addEventListener('azv:focus-search', onFocusSearch); window.addEventListener('azv:delete-by-prefix', onDeleteByPrefix); + window.addEventListener('azv:import-secrets', onImportSecrets); return () => { window.removeEventListener('azv:new-secret', onNewSecret); window.removeEventListener('azv:focus-search', onFocusSearch); window.removeEventListener('azv:delete-by-prefix', onDeleteByPrefix); + window.removeEventListener('azv:import-secrets', onImportSecrets); }; }, []); + const handleImportButtonClick = () => { + const input = importInputRef.current; + if (!input) return; + input.value = ''; + input.click(); + }; + + const handleImportFromFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !selectedVaultUri) return; + + setImportLoading(true); + setImportMessage(null); + + try { + const content = await file.text(); + const { requests } = parseSecretsImportJson(content); + const existingLower = new Map(allSecrets.map((s) => [s.name.toLowerCase(), s.name])); + const importNameCounts = new Map(); + const importNameCanonical = new Map(); + for (const request of requests) { + const key = request.name.toLowerCase(); + importNameCounts.set(key, (importNameCounts.get(key) ?? 0) + 1); + if (!importNameCanonical.has(key)) { + importNameCanonical.set(key, request.name); + } + } + const duplicateNamesInFile = Array.from(importNameCounts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => importNameCanonical.get(name) ?? name) + .sort((a, b) => a.localeCompare(b)); + const existingSecretNames = requests + .filter((request) => existingLower.has(request.name.toLowerCase())) + .map((request) => existingLower.get(request.name.toLowerCase()) ?? request.name); + const existingUnique = Array.from(new Set(existingSecretNames)).sort((a, b) => + a.localeCompare(b), + ); + + setPendingImport({ + fileName: file.name, + fileSizeBytes: file.size, + requests, + duplicateNamesInFile, + existingSecretNames: existingUnique, + }); + setShowImportConfirm(true); + } catch (e) { + setImportMessageTone('error'); + setImportMessage(`Import failed: ${String(e)}`); + } finally { + setImportLoading(false); + } + }; + + const handleConfirmImport = async () => { + if (!pendingImport || !selectedVaultUri) return; + const { requests, fileName } = pendingImport; + + setImportLoading(true); + setImportMessage(null); + + try { + let successCount = 0; + const failures: string[] = []; + + for (const request of requests) { + try { + await setSecret(selectedVaultUri, request); + successCount += 1; + } catch (e) { + failures.push(`${request.name}: ${String(e)}`); + } + } + + await secretsQuery.refetch(); + + if (failures.length === 0) { + setImportMessageTone('success'); + setImportMessage(`Imported ${successCount} secret(s) from ${fileName}.`); + } else { + const failedNamesPreview = failures + .slice(0, 3) + .map((entry) => entry.split(':', 1)[0]) + .join(', '); + const remaining = failures.length - 3; + const previewSuffix = remaining > 0 ? ` (+${remaining} more)` : ''; + setImportMessageTone('error'); + setImportMessage( + `Imported ${successCount}/${requests.length} from ${fileName}. Failed: ${failures.length} secret(s): ${failedNamesPreview}${previewSuffix}. First error: ${failures[0]}`, + ); + } + } finally { + setImportLoading(false); + setShowImportConfirm(false); + setPendingImport(null); + } + }; + const downloadExport = (content: string, format: ExportFormat) => { const mimeType = format === 'json' ? 'application/json' : 'text/csv;charset=utf-8'; const blob = new Blob([content], { type: mimeType }); @@ -430,13 +603,16 @@ export function SecretsList() { />
+ - @@ -447,11 +623,20 @@ export function SecretsList() { + @@ -509,6 +694,29 @@ export function SecretsList() {
)} + {importMessage && ( +
+ + {importMessage} + +
+ )} + {/* Table */}
{secretsQuery.isLoading ? ( @@ -617,9 +825,7 @@ export function SecretsList() {
)} - {bulkDeleteError && ( -
{bulkDeleteError}
- )} + {bulkDeleteError &&
{bulkDeleteError}
} {/* Delete by prefix */} @@ -638,6 +844,98 @@ export function SecretsList() { secretsQuery.refetch(); }} /> + + { + if (!data.open && !importLoading) { + setShowImportConfirm(false); + setPendingImport(null); + } + }} + > + + + Confirm Secret Import + + + Review file contents before importing. This operation creates new secrets or new + versions for existing names. + + + {pendingImport && ( + <> +
+ File + {pendingImport.fileName} + Size + + {formatFileSize(pendingImport.fileSizeBytes)} + + Secrets in file + + {pendingImport.requests.length} + + Will update existing + + {pendingImport.existingSecretNames.length} + +
+ + {pendingImport.duplicateNamesInFile.length > 0 && ( +
+ File contains duplicate names: {pendingImport.duplicateNamesInFile.join(', ')} +
+ )} + + {pendingImport.existingSecretNames.length > 0 && ( +
+ Existing secrets matched (new versions will be created):{' '} + {pendingImport.existingSecretNames.slice(0, 5).join(', ')} + {pendingImport.existingSecretNames.length > 5 + ? ` (+${pendingImport.existingSecretNames.length - 5} more)` + : ''} +
+ )} + +
+ Secrets to import +
+ {pendingImport.requests.slice(0, 30).map((request, index) => ( +
+ + {request.name} + +
+ ))} + {pendingImport.requests.length > 30 && ( + + +{pendingImport.requests.length - 30} more + + )} +
+
+ + )} +
+ + + + +
+
+
); diff --git a/src/components/secrets/secretsImport.test.ts b/src/components/secrets/secretsImport.test.ts new file mode 100644 index 0000000..4afbaac --- /dev/null +++ b/src/components/secrets/secretsImport.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { parseSecretsImportJson } from './secretsImport'; + +describe('secretsImport', () => { + it('parses array form', () => { + const input = JSON.stringify([ + { + name: 'api-key', + value: 'top-secret', + contentType: 'text/plain', + enabled: true, + expires: '2030-01-01T00:00:00Z', + tags: { env: 'prod' }, + }, + ]); + + const out = parseSecretsImportJson(input); + expect(out.requests).toHaveLength(1); + expect(out.requests[0]).toEqual({ + name: 'api-key', + value: 'top-secret', + contentType: 'text/plain', + enabled: true, + expires: '2030-01-01T00:00:00.000Z', + notBefore: null, + tags: { env: 'prod' }, + }); + }); + + it('parses envelope form', () => { + const input = JSON.stringify({ + secrets: [{ name: 'db-pass', value: '12345' }], + }); + + const out = parseSecretsImportJson(input); + expect(out.requests).toHaveLength(1); + expect(out.requests[0].name).toBe('db-pass'); + }); + + it('rejects invalid secret names', () => { + const input = JSON.stringify([{ name: 'bad name', value: '123' }]); + expect(() => parseSecretsImportJson(input)).toThrow(/letters, numbers, and dashes/); + }); + + it('rejects invalid tags shape', () => { + const input = JSON.stringify([{ name: 'ok-name', value: '123', tags: 'env=prod' }]); + expect(() => parseSecretsImportJson(input)).toThrow(/tags/); + }); +}); diff --git a/src/components/secrets/secretsImport.ts b/src/components/secrets/secretsImport.ts new file mode 100644 index 0000000..1782ccd --- /dev/null +++ b/src/components/secrets/secretsImport.ts @@ -0,0 +1,121 @@ +import type { CreateSecretRequest } from '../../types'; + +const SECRET_NAME_PATTERN = /^[a-zA-Z0-9-]+$/; + +interface RawImportItem { + name?: unknown; + value?: unknown; + contentType?: unknown; + enabled?: unknown; + expires?: unknown; + notBefore?: unknown; + tags?: unknown; +} + +interface ImportEnvelope { + secrets?: unknown; +} + +export interface ParsedSecretImport { + requests: CreateSecretRequest[]; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseDateField(value: unknown, field: string, index: number): string | null { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string') { + throw new Error(`Item ${index + 1}: '${field}' must be an ISO date string.`); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Item ${index + 1}: '${field}' is not a valid date.`); + } + return date.toISOString(); +} + +function parseTags(value: unknown, index: number): Record | null { + if (value === undefined || value === null) return null; + if (!isObject(value)) { + throw new Error(`Item ${index + 1}: 'tags' must be an object of string values.`); + } + const tags: Record = {}; + for (const [key, tagValue] of Object.entries(value)) { + if (typeof tagValue !== 'string') { + throw new Error(`Item ${index + 1}: tag '${key}' must be a string.`); + } + tags[key] = tagValue; + } + return Object.keys(tags).length > 0 ? tags : null; +} + +function normalizeItems(parsed: unknown): RawImportItem[] { + if (Array.isArray(parsed)) return parsed as RawImportItem[]; + if (isObject(parsed)) { + const envelope = parsed as ImportEnvelope; + if (Array.isArray(envelope.secrets)) return envelope.secrets as RawImportItem[]; + } + throw new Error("JSON must be an array or an object with a 'secrets' array."); +} + +function parseItem(item: RawImportItem, index: number): CreateSecretRequest { + if (!isObject(item)) { + throw new Error(`Item ${index + 1}: each entry must be an object.`); + } + + const name = typeof item.name === 'string' ? item.name.trim() : ''; + if (!name) { + throw new Error(`Item ${index + 1}: 'name' is required.`); + } + if (!SECRET_NAME_PATTERN.test(name)) { + throw new Error(`Item ${index + 1}: 'name' may only contain letters, numbers, and dashes.`); + } + + if (typeof item.value !== 'string' || !item.value.trim()) { + throw new Error(`Item ${index + 1}: 'value' is required and must be a non-empty string.`); + } + + if ( + item.contentType !== undefined && + item.contentType !== null && + typeof item.contentType !== 'string' + ) { + throw new Error(`Item ${index + 1}: 'contentType' must be a string when provided.`); + } + + if (item.enabled !== undefined && item.enabled !== null && typeof item.enabled !== 'boolean') { + throw new Error(`Item ${index + 1}: 'enabled' must be a boolean when provided.`); + } + + return { + name, + value: item.value, + contentType: + typeof item.contentType === 'string' && item.contentType.trim() + ? item.contentType.trim() + : null, + enabled: typeof item.enabled === 'boolean' ? item.enabled : true, + expires: parseDateField(item.expires, 'expires', index), + notBefore: parseDateField(item.notBefore, 'notBefore', index), + tags: parseTags(item.tags, index), + }; +} + +export function parseSecretsImportJson(input: string): ParsedSecretImport { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + throw new Error('Invalid JSON file.'); + } + + const items = normalizeItems(parsed); + if (items.length === 0) { + throw new Error('Import file is empty.'); + } + + const requests = items.map((item, index) => parseItem(item, index)); + return { requests }; +} diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index 6bdeb3c..b825186 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -8,10 +8,10 @@ import { DialogSurface, DialogTitle, Divider, + makeStyles, Option, Switch, Text, - makeStyles, tokens, } from '@fluentui/react-components'; import { Dismiss24Regular } from '@fluentui/react-icons'; @@ -262,13 +262,33 @@ export function SettingsDialog() { Keyboard Shortcuts
- + - - - + + + - +
diff --git a/src/components/vault/VaultDashboard.tsx b/src/components/vault/VaultDashboard.tsx index 7832a2d..2555b78 100644 --- a/src/components/vault/VaultDashboard.tsx +++ b/src/components/vault/VaultDashboard.tsx @@ -1,11 +1,4 @@ -import { - Badge, - Button, - Card, - makeStyles, - Text, - tokens, -} from '@fluentui/react-components'; +import { Badge, Button, Card, makeStyles, Text, tokens } from '@fluentui/react-components'; import { Add24Regular, Certificate24Regular,