create-ekka-desktop-app 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/bin/cli.js +72 -0
- package/package.json +23 -0
- package/template/branding/app.json +6 -0
- package/template/branding/icon.icns +0 -0
- package/template/eslint.config.js +98 -0
- package/template/index.html +29 -0
- package/template/package.json +40 -0
- package/template/src/app/App.tsx +24 -0
- package/template/src/demo/DemoApp.tsx +260 -0
- package/template/src/demo/components/Banner.tsx +82 -0
- package/template/src/demo/components/EmptyState.tsx +61 -0
- package/template/src/demo/components/InfoPopover.tsx +171 -0
- package/template/src/demo/components/InfoTooltip.tsx +76 -0
- package/template/src/demo/components/LearnMore.tsx +98 -0
- package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
- package/template/src/demo/components/SetupWizard.tsx +48 -0
- package/template/src/demo/components/StatusBadge.tsx +83 -0
- package/template/src/demo/components/index.ts +10 -0
- package/template/src/demo/hooks/index.ts +6 -0
- package/template/src/demo/hooks/useAuditEvents.ts +30 -0
- package/template/src/demo/layout/Shell.tsx +110 -0
- package/template/src/demo/layout/Sidebar.tsx +192 -0
- package/template/src/demo/pages/AuditLogPage.tsx +235 -0
- package/template/src/demo/pages/DocGenPage.tsx +874 -0
- package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
- package/template/src/demo/pages/LoginPage.tsx +192 -0
- package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
- package/template/src/demo/pages/RunnerPage.tsx +445 -0
- package/template/src/demo/pages/SystemPage.tsx +557 -0
- package/template/src/demo/pages/VaultPage.tsx +805 -0
- package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
- package/template/src/ekka/audit/index.ts +7 -0
- package/template/src/ekka/audit/store.ts +68 -0
- package/template/src/ekka/audit/types.ts +22 -0
- package/template/src/ekka/auth/client.ts +212 -0
- package/template/src/ekka/auth/index.ts +30 -0
- package/template/src/ekka/auth/storage.ts +114 -0
- package/template/src/ekka/auth/types.ts +67 -0
- package/template/src/ekka/backend/demo.ts +151 -0
- package/template/src/ekka/backend/interface.ts +36 -0
- package/template/src/ekka/config.ts +48 -0
- package/template/src/ekka/constants.ts +143 -0
- package/template/src/ekka/errors.ts +54 -0
- package/template/src/ekka/index.ts +516 -0
- package/template/src/ekka/internal/backend.ts +156 -0
- package/template/src/ekka/internal/index.ts +7 -0
- package/template/src/ekka/ops/auth.ts +29 -0
- package/template/src/ekka/ops/debug.ts +68 -0
- package/template/src/ekka/ops/home.ts +101 -0
- package/template/src/ekka/ops/index.ts +16 -0
- package/template/src/ekka/ops/nodeCredentials.ts +131 -0
- package/template/src/ekka/ops/nodeSession.ts +145 -0
- package/template/src/ekka/ops/paths.ts +183 -0
- package/template/src/ekka/ops/runner.ts +86 -0
- package/template/src/ekka/ops/runtime.ts +31 -0
- package/template/src/ekka/ops/setup.ts +47 -0
- package/template/src/ekka/ops/vault.ts +459 -0
- package/template/src/ekka/ops/workflowRuns.ts +116 -0
- package/template/src/ekka/types.ts +82 -0
- package/template/src/ekka/utils/idempotency.ts +14 -0
- package/template/src/ekka/utils/index.ts +7 -0
- package/template/src/ekka/utils/time.ts +77 -0
- package/template/src/main.tsx +12 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/src-tauri/Cargo.toml +41 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +11 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.png +0 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/bootstrap.rs +37 -0
- package/template/src-tauri/src/commands.rs +1215 -0
- package/template/src-tauri/src/device_secret.rs +111 -0
- package/template/src-tauri/src/engine_process.rs +538 -0
- package/template/src-tauri/src/grants.rs +129 -0
- package/template/src-tauri/src/handlers/home.rs +65 -0
- package/template/src-tauri/src/handlers/mod.rs +7 -0
- package/template/src-tauri/src/handlers/paths.rs +128 -0
- package/template/src-tauri/src/handlers/vault.rs +680 -0
- package/template/src-tauri/src/main.rs +243 -0
- package/template/src-tauri/src/node_auth.rs +858 -0
- package/template/src-tauri/src/node_credentials.rs +541 -0
- package/template/src-tauri/src/node_runner.rs +882 -0
- package/template/src-tauri/src/node_vault_crypto.rs +113 -0
- package/template/src-tauri/src/node_vault_store.rs +267 -0
- package/template/src-tauri/src/ops/auth.rs +50 -0
- package/template/src-tauri/src/ops/home.rs +251 -0
- package/template/src-tauri/src/ops/mod.rs +7 -0
- package/template/src-tauri/src/ops/runtime.rs +21 -0
- package/template/src-tauri/src/state.rs +639 -0
- package/template/src-tauri/src/types.rs +84 -0
- package/template/src-tauri/tauri.conf.json +41 -0
- package/template/tsconfig.json +26 -0
- package/template/tsconfig.tsbuildinfo +1 -0
- package/template/vite.config.ts +34 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault Page
|
|
3
|
+
*
|
|
4
|
+
* Admin UI for managing secrets, bundles, folders, and audit log.
|
|
5
|
+
* Four tabs: Secrets, Bundles, Folders, Audit
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Secret values are NEVER displayed. Only metadata is shown.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useMemo, useCallback, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
11
|
+
import { ekka, type SecretMeta, type SecretType, type BundleMeta, type FileEntry, type AuditEvent } from '../../ekka';
|
|
12
|
+
import { EmptyState } from '../components/EmptyState';
|
|
13
|
+
import { Banner } from '../components/Banner';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
type VaultTab = 'secrets' | 'bundles' | 'files' | 'audit';
|
|
20
|
+
|
|
21
|
+
interface VaultPageProps {
|
|
22
|
+
darkMode: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// HELPER FUNCTIONS
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
const formatDate = (dateStr: string): string => {
|
|
30
|
+
try {
|
|
31
|
+
return new Date(dateStr).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
32
|
+
} catch {
|
|
33
|
+
return dateStr;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const formatDateTime = (dateStr: string): string => {
|
|
38
|
+
try {
|
|
39
|
+
return new Date(dateStr).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
40
|
+
} catch {
|
|
41
|
+
return dateStr;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const formatSecretType = (type: SecretType): string => {
|
|
46
|
+
const map: Record<SecretType, string> = { PASSWORD: 'Password', API_KEY: 'API Key', TOKEN: 'Token', CERTIFICATE: 'Certificate', SSH_KEY: 'SSH Key', GENERIC_TEXT: 'Generic' };
|
|
47
|
+
return map[type] || type;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const formatFileSize = (bytes?: number): string => {
|
|
51
|
+
if (bytes === undefined) return '-';
|
|
52
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
53
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
54
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// ICONS
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
const SecretIcon = () => (
|
|
62
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
63
|
+
<rect x="12" y="20" width="24" height="18" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
64
|
+
<path d="M18 20V14a6 6 0 1 1 12 0v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
65
|
+
<circle cx="24" cy="29" r="2" fill="currentColor" />
|
|
66
|
+
<path d="M24 31v4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
67
|
+
</svg>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const BundleIcon = () => (
|
|
71
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
72
|
+
<rect x="8" y="12" width="32" height="8" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
73
|
+
<rect x="8" y="24" width="32" height="8" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
74
|
+
<rect x="8" y="36" width="32" height="6" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
75
|
+
</svg>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const FilesIconLarge = () => (
|
|
79
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
80
|
+
<path d="M12 8h16l8 8v24a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4z" stroke="currentColor" strokeWidth="2" />
|
|
81
|
+
<path d="M28 8v8h8" stroke="currentColor" strokeWidth="2" />
|
|
82
|
+
<line x1="14" y1="24" x2="28" y2="24" stroke="currentColor" strokeWidth="2" />
|
|
83
|
+
<line x1="14" y1="30" x2="26" y2="30" stroke="currentColor" strokeWidth="2" />
|
|
84
|
+
<line x1="14" y1="36" x2="24" y2="36" stroke="currentColor" strokeWidth="2" />
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const FolderIcon = () => (
|
|
89
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
90
|
+
<path d="M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.764.382 2.236 1l.472.707c.188.282.51.543 1.028.543h4.5A1.5 1.5 0 0 1 15 5.75v6.75A1.5 1.5 0 0 1 13.5 14h-11A1.5 1.5 0 0 1 1 12.5v-9zm1.5-.5a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V5.75a.5.5 0 0 0-.5-.5H9a2.016 2.016 0 0 1-1.528-.793l-.472-.707C6.764 3.393 6.366 3 5.264 3H2.5z" />
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const AuditIcon = () => (
|
|
95
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
96
|
+
<rect x="10" y="8" width="28" height="32" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
97
|
+
<line x1="16" y1="16" x2="32" y2="16" stroke="currentColor" strokeWidth="2" />
|
|
98
|
+
<line x1="16" y1="24" x2="28" y2="24" stroke="currentColor" strokeWidth="2" />
|
|
99
|
+
<line x1="16" y1="32" x2="30" y2="32" stroke="currentColor" strokeWidth="2" />
|
|
100
|
+
</svg>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const TrashIcon = () => (
|
|
104
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
105
|
+
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
|
106
|
+
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
|
107
|
+
</svg>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const RotateIcon = () => (
|
|
111
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
112
|
+
<path fillRule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
|
|
113
|
+
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
|
|
114
|
+
</svg>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const ViewIcon = () => (
|
|
118
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
119
|
+
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
|
120
|
+
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
|
121
|
+
</svg>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const EditIcon = () => (
|
|
125
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
126
|
+
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// MAIN COMPONENT
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
export function VaultPage({ darkMode }: VaultPageProps): ReactElement {
|
|
135
|
+
const [activeTab, setActiveTab] = useState<VaultTab>('secrets');
|
|
136
|
+
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// COLORS & STYLES (memoized)
|
|
139
|
+
// =============================================================================
|
|
140
|
+
|
|
141
|
+
const colors = useMemo(() => ({
|
|
142
|
+
text: darkMode ? '#ffffff' : '#1d1d1f',
|
|
143
|
+
textMuted: darkMode ? '#98989d' : '#6e6e73',
|
|
144
|
+
textDim: darkMode ? '#636366' : '#aeaeb2',
|
|
145
|
+
bg: darkMode ? '#2c2c2e' : '#fafafa',
|
|
146
|
+
bgAlt: darkMode ? '#1c1c1e' : '#ffffff',
|
|
147
|
+
bgInput: darkMode ? '#3a3a3c' : '#ffffff',
|
|
148
|
+
border: darkMode ? '#3a3a3c' : '#e5e5e5',
|
|
149
|
+
accent: darkMode ? '#0a84ff' : '#007aff',
|
|
150
|
+
green: darkMode ? '#30d158' : '#34c759',
|
|
151
|
+
orange: darkMode ? '#ff9f0a' : '#ff9500',
|
|
152
|
+
red: darkMode ? '#ff453a' : '#ff3b30',
|
|
153
|
+
purple: darkMode ? '#bf5af2' : '#af52de',
|
|
154
|
+
}), [darkMode]);
|
|
155
|
+
|
|
156
|
+
const styles = useMemo((): Record<string, CSSProperties> => ({
|
|
157
|
+
container: { width: '100%' },
|
|
158
|
+
header: { marginBottom: '24px' },
|
|
159
|
+
title: { fontSize: '28px', fontWeight: 700, color: colors.text, marginBottom: '8px', letterSpacing: '-0.02em' },
|
|
160
|
+
subtitle: { fontSize: '14px', color: colors.textMuted, lineHeight: 1.6, maxWidth: '600px' },
|
|
161
|
+
tabNav: { display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: `1px solid ${colors.border}`, paddingBottom: '0' },
|
|
162
|
+
tabButton: { padding: '10px 16px', fontSize: '13px', fontWeight: 500, color: colors.textMuted, background: 'transparent', border: 'none', borderBottom: '2px solid transparent', cursor: 'pointer', marginBottom: '-1px' },
|
|
163
|
+
tabButtonActive: { color: colors.accent, borderBottomColor: colors.accent },
|
|
164
|
+
card: { background: colors.bg, border: `1px solid ${colors.border}`, borderRadius: '12px', padding: '20px' },
|
|
165
|
+
toolbar: { display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' as const, alignItems: 'center' },
|
|
166
|
+
searchInput: { flex: '1 1 200px', minWidth: '150px', maxWidth: '300px', padding: '8px 12px', fontSize: '13px', background: colors.bgInput, border: `1px solid ${colors.border}`, borderRadius: '6px', color: colors.text, outline: 'none' },
|
|
167
|
+
select: { padding: '8px 12px', fontSize: '13px', background: colors.bgInput, border: `1px solid ${colors.border}`, borderRadius: '6px', color: colors.text, outline: 'none', cursor: 'pointer' },
|
|
168
|
+
button: { padding: '8px 16px', fontSize: '13px', fontWeight: 600, color: '#ffffff', background: colors.accent, border: 'none', borderRadius: '6px', cursor: 'pointer', whiteSpace: 'nowrap' as const },
|
|
169
|
+
buttonSecondary: { padding: '8px 16px', fontSize: '13px', fontWeight: 600, color: colors.accent, background: darkMode ? 'rgba(10, 132, 255, 0.15)' : 'rgba(0, 122, 255, 0.1)', border: 'none', borderRadius: '6px', cursor: 'pointer', whiteSpace: 'nowrap' as const },
|
|
170
|
+
buttonDanger: { padding: '8px 16px', fontSize: '13px', fontWeight: 600, color: colors.red, background: darkMode ? 'rgba(255, 69, 58, 0.15)' : 'rgba(255, 59, 48, 0.1)', border: 'none', borderRadius: '6px', cursor: 'pointer', whiteSpace: 'nowrap' as const },
|
|
171
|
+
buttonDisabled: { opacity: 0.5, cursor: 'not-allowed' },
|
|
172
|
+
table: { width: '100%', borderCollapse: 'collapse' as const },
|
|
173
|
+
tableHeader: { textAlign: 'left' as const, padding: '12px 16px', fontSize: '11px', fontWeight: 600, color: colors.textMuted, textTransform: 'uppercase' as const, letterSpacing: '0.04em', borderBottom: `1px solid ${colors.border}` },
|
|
174
|
+
tableCell: { padding: '12px 16px', fontSize: '13px', color: colors.text, borderBottom: `1px solid ${colors.border}` },
|
|
175
|
+
tableCellMono: { padding: '12px 16px', fontSize: '12px', fontFamily: 'SF Mono, Monaco, Consolas, monospace', color: colors.text, borderBottom: `1px solid ${colors.border}`, maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
|
|
176
|
+
tableDangerButton: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '28px', height: '28px', padding: 0, background: 'transparent', border: 'none', borderRadius: '4px', cursor: 'pointer', color: colors.red, opacity: 0.7 },
|
|
177
|
+
tableActionButton: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '28px', height: '28px', padding: 0, background: 'transparent', border: 'none', borderRadius: '4px', cursor: 'pointer', color: colors.accent, opacity: 0.7 },
|
|
178
|
+
badge: { display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '3px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 600 },
|
|
179
|
+
badgeBlue: { background: darkMode ? 'rgba(10, 132, 255, 0.15)' : 'rgba(0, 122, 255, 0.12)', color: colors.accent },
|
|
180
|
+
badgePurple: { background: darkMode ? 'rgba(191, 90, 242, 0.15)' : 'rgba(175, 82, 222, 0.12)', color: colors.purple },
|
|
181
|
+
notImplementedCard: { background: darkMode ? 'rgba(255, 159, 10, 0.08)' : 'rgba(255, 149, 0, 0.06)', border: `1px solid ${darkMode ? 'rgba(255, 159, 10, 0.2)' : 'rgba(255, 149, 0, 0.15)'}`, borderRadius: '12px', padding: '24px', textAlign: 'center' as const },
|
|
182
|
+
notImplementedTitle: { fontSize: '16px', fontWeight: 600, color: colors.orange, marginBottom: '8px' },
|
|
183
|
+
notImplementedText: { fontSize: '13px', color: colors.textMuted, lineHeight: 1.5 },
|
|
184
|
+
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0, 0, 0, 0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
|
185
|
+
modal: { background: darkMode ? '#2c2c2e' : '#ffffff', borderRadius: '12px', padding: '24px', width: '100%', maxWidth: '440px', boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)' },
|
|
186
|
+
modalTitle: { fontSize: '18px', fontWeight: 600, color: colors.text, marginBottom: '20px' },
|
|
187
|
+
inputGroup: { marginBottom: '16px' },
|
|
188
|
+
label: { display: 'block', fontSize: '12px', fontWeight: 600, color: colors.textMuted, marginBottom: '6px', textTransform: 'uppercase' as const, letterSpacing: '0.04em' },
|
|
189
|
+
input: { width: '100%', padding: '10px 12px', fontSize: '13px', background: colors.bgInput, border: `1px solid ${colors.border}`, borderRadius: '8px', color: colors.text, outline: 'none', boxSizing: 'border-box' as const },
|
|
190
|
+
modalActions: { display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' },
|
|
191
|
+
folderTree: { display: 'flex', gap: '20px' },
|
|
192
|
+
folderTreeLeft: { flex: '1 1 300px', minWidth: '200px' },
|
|
193
|
+
folderTreeRight: { flex: '1 1 300px', minWidth: '200px' },
|
|
194
|
+
folderItem: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 12px', borderRadius: '8px', cursor: 'pointer' },
|
|
195
|
+
folderItemSelected: { background: darkMode ? 'rgba(10, 132, 255, 0.15)' : 'rgba(0, 122, 255, 0.1)' },
|
|
196
|
+
folderIcon: { color: colors.accent },
|
|
197
|
+
folderName: { flex: 1, fontSize: '13px', color: colors.text },
|
|
198
|
+
detailPanel: { background: darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.02)', borderRadius: '8px', padding: '16px' },
|
|
199
|
+
detailRow: { display: 'flex', gap: '12px', marginBottom: '8px', fontSize: '13px' },
|
|
200
|
+
detailLabel: { color: colors.textMuted, minWidth: '80px' },
|
|
201
|
+
detailValue: { color: colors.text },
|
|
202
|
+
bundleDrawer: { marginTop: '16px', background: darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.02)', borderRadius: '12px', padding: '16px' },
|
|
203
|
+
bundleDrawerHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' },
|
|
204
|
+
bundleDrawerTitle: { fontSize: '15px', fontWeight: 600, color: colors.text },
|
|
205
|
+
pagination: { display: 'flex', gap: '8px', justifyContent: 'center', marginTop: '16px' },
|
|
206
|
+
loadingOverlay: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px', color: colors.textMuted, fontSize: '14px' },
|
|
207
|
+
}), [colors, darkMode]);
|
|
208
|
+
|
|
209
|
+
// =============================================================================
|
|
210
|
+
// RENDER
|
|
211
|
+
// =============================================================================
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div style={styles.container}>
|
|
215
|
+
<header style={styles.header}>
|
|
216
|
+
<h1 style={styles.title}>Vault</h1>
|
|
217
|
+
<p style={styles.subtitle}>Securely manage secrets, organize them into bundles and folders, and track all access.</p>
|
|
218
|
+
</header>
|
|
219
|
+
|
|
220
|
+
<div style={styles.tabNav}>
|
|
221
|
+
{(['secrets', 'bundles', 'files', 'audit'] as const).map((tab) => (
|
|
222
|
+
<button key={tab} onClick={() => setActiveTab(tab)} style={{ ...styles.tabButton, ...(activeTab === tab ? styles.tabButtonActive : {}) }}>
|
|
223
|
+
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
224
|
+
</button>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{activeTab === 'secrets' && <SecretsTab darkMode={darkMode} colors={colors} styles={styles} />}
|
|
229
|
+
{activeTab === 'bundles' && <BundlesTab darkMode={darkMode} colors={colors} styles={styles} />}
|
|
230
|
+
{activeTab === 'files' && <FilesTab darkMode={darkMode} colors={colors} styles={styles} />}
|
|
231
|
+
{activeTab === 'audit' && <AuditTab darkMode={darkMode} colors={colors} styles={styles} />}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// =============================================================================
|
|
237
|
+
// SECRETS TAB (separate component to isolate hook)
|
|
238
|
+
// =============================================================================
|
|
239
|
+
|
|
240
|
+
function SecretsTab({ darkMode, colors, styles }: { darkMode: boolean; colors: Record<string, string>; styles: Record<string, CSSProperties> }): ReactElement {
|
|
241
|
+
const [secrets, setSecrets] = useState<SecretMeta[]>([]);
|
|
242
|
+
const [bundles, setBundles] = useState<BundleMeta[]>([]);
|
|
243
|
+
const [loading, setLoading] = useState(false);
|
|
244
|
+
const [error, setError] = useState<string | null>(null);
|
|
245
|
+
const [notImplemented, setNotImplemented] = useState(false);
|
|
246
|
+
const [search, setSearch] = useState('');
|
|
247
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
248
|
+
const [showRotateModal, setShowRotateModal] = useState<SecretMeta | null>(null);
|
|
249
|
+
const [newSecret, setNewSecret] = useState({ name: '', value: '', bundleId: '', secretType: 'GENERIC_TEXT' as SecretType, tags: '' });
|
|
250
|
+
const [rotateValue, setRotateValue] = useState('');
|
|
251
|
+
|
|
252
|
+
// Ref guards to prevent double-loads in StrictMode
|
|
253
|
+
const didLoadSecretsRef = useRef(false);
|
|
254
|
+
const didLoadBundlesRef = useRef(false);
|
|
255
|
+
|
|
256
|
+
const loadSecrets = useCallback(async () => {
|
|
257
|
+
setLoading(true);
|
|
258
|
+
setError(null);
|
|
259
|
+
try {
|
|
260
|
+
const result = await ekka.vault.secrets.list();
|
|
261
|
+
setSecrets(result);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err instanceof Error && (err.message.includes('not implemented') || err.message.includes('op_unknown'))) {
|
|
264
|
+
setNotImplemented(true);
|
|
265
|
+
} else {
|
|
266
|
+
setError(err instanceof Error ? err.message : 'Failed to load');
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
setLoading(false);
|
|
270
|
+
}
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
const loadBundles = useCallback(async () => {
|
|
274
|
+
if (didLoadBundlesRef.current) return;
|
|
275
|
+
didLoadBundlesRef.current = true;
|
|
276
|
+
try {
|
|
277
|
+
const result = await ekka.vault.bundles.list();
|
|
278
|
+
setBundles(result);
|
|
279
|
+
} catch { /* ignore */ }
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
// Load secrets only once on mount
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (didLoadSecretsRef.current) return;
|
|
285
|
+
didLoadSecretsRef.current = true;
|
|
286
|
+
void loadSecrets();
|
|
287
|
+
}, [loadSecrets]);
|
|
288
|
+
|
|
289
|
+
const handleCreate = async () => {
|
|
290
|
+
setLoading(true);
|
|
291
|
+
try {
|
|
292
|
+
await ekka.vault.secrets.create({ name: newSecret.name, value: newSecret.value, bundleId: newSecret.bundleId || undefined, secretType: newSecret.secretType, tags: newSecret.tags ? newSecret.tags.split(',').map(t => t.trim()) : undefined });
|
|
293
|
+
setShowCreateModal(false);
|
|
294
|
+
setNewSecret({ name: '', value: '', bundleId: '', secretType: 'GENERIC_TEXT', tags: '' });
|
|
295
|
+
void loadSecrets();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
setError(err instanceof Error ? err.message : 'Failed to create');
|
|
298
|
+
} finally {
|
|
299
|
+
setLoading(false);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const handleRotate = async () => {
|
|
304
|
+
if (!showRotateModal) return;
|
|
305
|
+
setLoading(true);
|
|
306
|
+
try {
|
|
307
|
+
await ekka.vault.secrets.update(showRotateModal.id, { value: rotateValue });
|
|
308
|
+
setShowRotateModal(null);
|
|
309
|
+
setRotateValue('');
|
|
310
|
+
void loadSecrets();
|
|
311
|
+
} catch (err) {
|
|
312
|
+
setError(err instanceof Error ? err.message : 'Failed to rotate');
|
|
313
|
+
} finally {
|
|
314
|
+
setLoading(false);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const handleDelete = async (id: string) => {
|
|
319
|
+
if (!confirm('Delete this secret?')) return;
|
|
320
|
+
try {
|
|
321
|
+
await ekka.vault.secrets.delete(id);
|
|
322
|
+
void loadSecrets();
|
|
323
|
+
} catch (err) {
|
|
324
|
+
setError(err instanceof Error ? err.message : 'Failed to delete');
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if (notImplemented) return <div style={styles.notImplementedCard}><div style={styles.notImplementedTitle}>Backend Not Implemented</div><p style={styles.notImplementedText}>The secrets backend is not yet available.</p></div>;
|
|
329
|
+
|
|
330
|
+
const filtered = search ? secrets.filter(s => s.name.toLowerCase().includes(search.toLowerCase())) : secrets;
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<>
|
|
334
|
+
{error && <div style={{ marginBottom: '16px' }}><Banner type="error" message={error} darkMode={darkMode} /></div>}
|
|
335
|
+
<div style={styles.toolbar}>
|
|
336
|
+
<input type="text" placeholder="Filter secrets..." value={search} onChange={e => setSearch(e.target.value)} style={styles.searchInput} />
|
|
337
|
+
<button onClick={() => void loadSecrets()} style={styles.buttonSecondary} disabled={loading}>Refresh</button>
|
|
338
|
+
<div style={{ flex: 1 }} />
|
|
339
|
+
<button onClick={() => { setShowCreateModal(true); void loadBundles(); }} style={styles.button}>Create Secret</button>
|
|
340
|
+
</div>
|
|
341
|
+
<div style={{ ...styles.card, padding: 0, overflowX: 'auto' }}>
|
|
342
|
+
{loading && secrets.length === 0 ? <div style={styles.loadingOverlay}>Loading...</div> : filtered.length === 0 ? <EmptyState icon={<SecretIcon />} message="No secrets yet" hint="Create your first secret." darkMode={darkMode} /> : (
|
|
343
|
+
<table style={styles.table}>
|
|
344
|
+
<thead><tr><th style={styles.tableHeader}>Name</th><th style={styles.tableHeader}>Type</th><th style={styles.tableHeader}>Tags</th><th style={styles.tableHeader}>Updated</th><th style={{ ...styles.tableHeader, width: '100px', textAlign: 'right' }}>Actions</th></tr></thead>
|
|
345
|
+
<tbody>
|
|
346
|
+
{filtered.map(s => (
|
|
347
|
+
<tr key={s.id}>
|
|
348
|
+
<td style={styles.tableCellMono}>{s.name}</td>
|
|
349
|
+
<td style={styles.tableCell}><span style={{ ...styles.badge, ...styles.badgeBlue }}>{formatSecretType(s.secretType)}</span></td>
|
|
350
|
+
<td style={styles.tableCell}>{s.tags.length > 0 ? s.tags.slice(0, 2).map(t => <span key={t} style={{ ...styles.badge, ...styles.badgePurple, marginRight: 4 }}>{t}</span>) : <span style={{ color: colors.textDim }}>-</span>}</td>
|
|
351
|
+
<td style={styles.tableCell}>{formatDate(s.updatedAt)}</td>
|
|
352
|
+
<td style={styles.tableCell}><div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}><button onClick={() => setShowRotateModal(s)} style={styles.tableActionButton} title="Rotate"><RotateIcon /></button><button onClick={() => void handleDelete(s.id)} style={styles.tableDangerButton} title="Delete"><TrashIcon /></button></div></td>
|
|
353
|
+
</tr>
|
|
354
|
+
))}
|
|
355
|
+
</tbody>
|
|
356
|
+
</table>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
{showCreateModal && (
|
|
360
|
+
<div style={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
|
361
|
+
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
|
362
|
+
<h2 style={styles.modalTitle}>Create Secret</h2>
|
|
363
|
+
<div style={styles.inputGroup}><label style={styles.label}>Name</label><input type="text" value={newSecret.name} onChange={e => setNewSecret({ ...newSecret, name: e.target.value })} style={styles.input} /></div>
|
|
364
|
+
<div style={styles.inputGroup}><label style={styles.label}>Value</label><input type="password" value={newSecret.value} onChange={e => setNewSecret({ ...newSecret, value: e.target.value })} style={styles.input} /></div>
|
|
365
|
+
<div style={styles.inputGroup}><label style={styles.label}>Type</label><select value={newSecret.secretType} onChange={e => setNewSecret({ ...newSecret, secretType: e.target.value as SecretType })} style={styles.select}><option value="GENERIC_TEXT">Generic</option><option value="PASSWORD">Password</option><option value="API_KEY">API Key</option><option value="TOKEN">Token</option></select></div>
|
|
366
|
+
<div style={styles.inputGroup}><label style={styles.label}>Bundle</label><select value={newSecret.bundleId} onChange={e => setNewSecret({ ...newSecret, bundleId: e.target.value })} style={styles.select}><option value="">None</option>{bundles.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}</select></div>
|
|
367
|
+
<div style={styles.inputGroup}><label style={styles.label}>Tags</label><input type="text" value={newSecret.tags} onChange={e => setNewSecret({ ...newSecret, tags: e.target.value })} placeholder="tag1, tag2" style={styles.input} /></div>
|
|
368
|
+
<div style={styles.modalActions}><button onClick={() => setShowCreateModal(false)} style={styles.buttonSecondary}>Cancel</button><button onClick={() => void handleCreate()} style={{ ...styles.button, ...(!newSecret.name || !newSecret.value ? styles.buttonDisabled : {}) }} disabled={!newSecret.name || !newSecret.value || loading}>{loading ? 'Creating...' : 'Create'}</button></div>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
{showRotateModal && (
|
|
373
|
+
<div style={styles.modalOverlay} onClick={() => setShowRotateModal(null)}>
|
|
374
|
+
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
|
375
|
+
<h2 style={styles.modalTitle}>Rotate Secret</h2>
|
|
376
|
+
<p style={{ fontSize: '13px', color: colors.textMuted, marginBottom: '20px' }}>Secret: <strong>{showRotateModal.name}</strong></p>
|
|
377
|
+
<div style={styles.inputGroup}><label style={styles.label}>New Value</label><input type="password" value={rotateValue} onChange={e => setRotateValue(e.target.value)} style={styles.input} /></div>
|
|
378
|
+
<div style={styles.modalActions}><button onClick={() => setShowRotateModal(null)} style={styles.buttonSecondary}>Cancel</button><button onClick={() => void handleRotate()} style={{ ...styles.button, ...(!rotateValue ? styles.buttonDisabled : {}) }} disabled={!rotateValue || loading}>{loading ? 'Rotating...' : 'Rotate'}</button></div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// BUNDLES TAB
|
|
388
|
+
// =============================================================================
|
|
389
|
+
|
|
390
|
+
function BundlesTab({ darkMode, styles }: { darkMode: boolean; colors: Record<string, string>; styles: Record<string, CSSProperties> }): ReactElement {
|
|
391
|
+
const [bundles, setBundles] = useState<BundleMeta[]>([]);
|
|
392
|
+
const [loading, setLoading] = useState(false);
|
|
393
|
+
const [error, setError] = useState<string | null>(null);
|
|
394
|
+
const [notImplemented, setNotImplemented] = useState(false);
|
|
395
|
+
const [search, setSearch] = useState('');
|
|
396
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
397
|
+
const [showRenameModal, setShowRenameModal] = useState<BundleMeta | null>(null);
|
|
398
|
+
const [selectedBundle, setSelectedBundle] = useState<BundleMeta | null>(null);
|
|
399
|
+
const [bundleSecrets, setBundleSecrets] = useState<SecretMeta[]>([]);
|
|
400
|
+
const [newName, setNewName] = useState('');
|
|
401
|
+
const [renameValue, setRenameValue] = useState('');
|
|
402
|
+
|
|
403
|
+
// Ref guard to prevent double-loads in StrictMode
|
|
404
|
+
const didLoadRef = useRef(false);
|
|
405
|
+
|
|
406
|
+
const loadBundles = useCallback(async () => {
|
|
407
|
+
setLoading(true);
|
|
408
|
+
setError(null);
|
|
409
|
+
try {
|
|
410
|
+
const result = await ekka.vault.bundles.list();
|
|
411
|
+
setBundles(result);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err instanceof Error && (err.message.includes('not implemented') || err.message.includes('op_unknown'))) {
|
|
414
|
+
setNotImplemented(true);
|
|
415
|
+
} else {
|
|
416
|
+
setError(err instanceof Error ? err.message : 'Failed to load');
|
|
417
|
+
}
|
|
418
|
+
} finally {
|
|
419
|
+
setLoading(false);
|
|
420
|
+
}
|
|
421
|
+
}, []);
|
|
422
|
+
|
|
423
|
+
// Load bundles only once on mount
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (didLoadRef.current) return;
|
|
426
|
+
didLoadRef.current = true;
|
|
427
|
+
void loadBundles();
|
|
428
|
+
}, [loadBundles]);
|
|
429
|
+
|
|
430
|
+
const loadBundleSecrets = useCallback(async (bundle: BundleMeta) => {
|
|
431
|
+
try {
|
|
432
|
+
const secrets = await Promise.all(bundle.secretIds.map(id => ekka.vault.secrets.get(id)));
|
|
433
|
+
setBundleSecrets(secrets);
|
|
434
|
+
} catch { setBundleSecrets([]); }
|
|
435
|
+
}, []);
|
|
436
|
+
|
|
437
|
+
useEffect(() => { if (selectedBundle) void loadBundleSecrets(selectedBundle); }, [selectedBundle, loadBundleSecrets]);
|
|
438
|
+
|
|
439
|
+
const handleCreate = async () => {
|
|
440
|
+
setLoading(true);
|
|
441
|
+
try {
|
|
442
|
+
await ekka.vault.bundles.create({ name: newName });
|
|
443
|
+
setShowCreateModal(false);
|
|
444
|
+
setNewName('');
|
|
445
|
+
void loadBundles();
|
|
446
|
+
} catch (err) {
|
|
447
|
+
setError(err instanceof Error ? err.message : 'Failed to create');
|
|
448
|
+
} finally {
|
|
449
|
+
setLoading(false);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const handleRename = async () => {
|
|
454
|
+
if (!showRenameModal) return;
|
|
455
|
+
setLoading(true);
|
|
456
|
+
try {
|
|
457
|
+
await ekka.vault.bundles.rename(showRenameModal.id, renameValue);
|
|
458
|
+
setShowRenameModal(null);
|
|
459
|
+
setRenameValue('');
|
|
460
|
+
void loadBundles();
|
|
461
|
+
} catch (err) {
|
|
462
|
+
setError(err instanceof Error ? err.message : 'Failed to rename');
|
|
463
|
+
} finally {
|
|
464
|
+
setLoading(false);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const handleDelete = async (id: string) => {
|
|
469
|
+
if (!confirm('Delete this bundle?')) return;
|
|
470
|
+
try {
|
|
471
|
+
await ekka.vault.bundles.delete(id);
|
|
472
|
+
if (selectedBundle?.id === id) setSelectedBundle(null);
|
|
473
|
+
void loadBundles();
|
|
474
|
+
} catch (err) {
|
|
475
|
+
setError(err instanceof Error ? err.message : 'Failed to delete');
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const handleRemoveSecret = async (secretId: string) => {
|
|
480
|
+
if (!selectedBundle) return;
|
|
481
|
+
try {
|
|
482
|
+
await ekka.vault.bundles.removeSecret(selectedBundle.id, secretId);
|
|
483
|
+
void loadBundles();
|
|
484
|
+
} catch (err) {
|
|
485
|
+
setError(err instanceof Error ? err.message : 'Failed to remove');
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
if (notImplemented) return <div style={styles.notImplementedCard}><div style={styles.notImplementedTitle}>Backend Not Implemented</div><p style={styles.notImplementedText}>The bundles backend is not yet available.</p></div>;
|
|
490
|
+
|
|
491
|
+
const filtered = search ? bundles.filter(b => b.name.toLowerCase().includes(search.toLowerCase())) : bundles;
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<>
|
|
495
|
+
{error && <div style={{ marginBottom: '16px' }}><Banner type="error" message={error} darkMode={darkMode} /></div>}
|
|
496
|
+
<div style={styles.toolbar}>
|
|
497
|
+
<input type="text" placeholder="Filter bundles..." value={search} onChange={e => setSearch(e.target.value)} style={styles.searchInput} />
|
|
498
|
+
<button onClick={() => void loadBundles()} style={styles.buttonSecondary} disabled={loading}>Refresh</button>
|
|
499
|
+
<div style={{ flex: 1 }} />
|
|
500
|
+
<button onClick={() => setShowCreateModal(true)} style={styles.button}>Create Bundle</button>
|
|
501
|
+
</div>
|
|
502
|
+
<div style={{ ...styles.card, padding: 0, overflowX: 'auto' }}>
|
|
503
|
+
{loading && bundles.length === 0 ? <div style={styles.loadingOverlay}>Loading...</div> : filtered.length === 0 ? <EmptyState icon={<BundleIcon />} message="No bundles yet" hint="Create your first bundle." darkMode={darkMode} /> : (
|
|
504
|
+
<table style={styles.table}>
|
|
505
|
+
<thead><tr><th style={styles.tableHeader}>Name</th><th style={styles.tableHeader}>Secrets</th><th style={styles.tableHeader}>Updated</th><th style={{ ...styles.tableHeader, width: '120px', textAlign: 'right' }}>Actions</th></tr></thead>
|
|
506
|
+
<tbody>
|
|
507
|
+
{filtered.map(b => (
|
|
508
|
+
<tr key={b.id}>
|
|
509
|
+
<td style={styles.tableCellMono}>{b.name}</td>
|
|
510
|
+
<td style={styles.tableCell}><span style={{ ...styles.badge, ...styles.badgeBlue }}>{b.secretIds.length}</span></td>
|
|
511
|
+
<td style={styles.tableCell}>{formatDate(b.updatedAt)}</td>
|
|
512
|
+
<td style={styles.tableCell}><div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}><button onClick={() => { setSelectedBundle(b); setBundleSecrets([]); }} style={styles.tableActionButton} title="View"><ViewIcon /></button><button onClick={() => { setShowRenameModal(b); setRenameValue(b.name); }} style={styles.tableActionButton} title="Rename"><EditIcon /></button><button onClick={() => void handleDelete(b.id)} style={styles.tableDangerButton} title="Delete"><TrashIcon /></button></div></td>
|
|
513
|
+
</tr>
|
|
514
|
+
))}
|
|
515
|
+
</tbody>
|
|
516
|
+
</table>
|
|
517
|
+
)}
|
|
518
|
+
</div>
|
|
519
|
+
{selectedBundle && (
|
|
520
|
+
<div style={styles.bundleDrawer}>
|
|
521
|
+
<div style={styles.bundleDrawerHeader}><div style={styles.bundleDrawerTitle}>Bundle: {selectedBundle.name}</div><button onClick={() => setSelectedBundle(null)} style={styles.buttonSecondary}>Close</button></div>
|
|
522
|
+
{bundleSecrets.length === 0 ? <EmptyState icon={<SecretIcon />} message="No secrets in bundle" hint="Add secrets to this bundle." darkMode={darkMode} /> : (
|
|
523
|
+
<table style={styles.table}>
|
|
524
|
+
<thead><tr><th style={styles.tableHeader}>Name</th><th style={styles.tableHeader}>Type</th><th style={{ ...styles.tableHeader, width: '80px', textAlign: 'right' }}>Actions</th></tr></thead>
|
|
525
|
+
<tbody>{bundleSecrets.map(s => <tr key={s.id}><td style={styles.tableCellMono}>{s.name}</td><td style={styles.tableCell}><span style={{ ...styles.badge, ...styles.badgeBlue }}>{formatSecretType(s.secretType)}</span></td><td style={styles.tableCell}><button onClick={() => void handleRemoveSecret(s.id)} style={styles.tableDangerButton} title="Remove"><TrashIcon /></button></td></tr>)}</tbody>
|
|
526
|
+
</table>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
{showCreateModal && (
|
|
531
|
+
<div style={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
|
532
|
+
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
|
533
|
+
<h2 style={styles.modalTitle}>Create Bundle</h2>
|
|
534
|
+
<div style={styles.inputGroup}><label style={styles.label}>Name</label><input type="text" value={newName} onChange={e => setNewName(e.target.value)} style={styles.input} /></div>
|
|
535
|
+
<div style={styles.modalActions}><button onClick={() => setShowCreateModal(false)} style={styles.buttonSecondary}>Cancel</button><button onClick={() => void handleCreate()} style={{ ...styles.button, ...(!newName ? styles.buttonDisabled : {}) }} disabled={!newName || loading}>{loading ? 'Creating...' : 'Create'}</button></div>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
539
|
+
{showRenameModal && (
|
|
540
|
+
<div style={styles.modalOverlay} onClick={() => setShowRenameModal(null)}>
|
|
541
|
+
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
|
542
|
+
<h2 style={styles.modalTitle}>Rename Bundle</h2>
|
|
543
|
+
<div style={styles.inputGroup}><label style={styles.label}>New Name</label><input type="text" value={renameValue} onChange={e => setRenameValue(e.target.value)} style={styles.input} /></div>
|
|
544
|
+
<div style={styles.modalActions}><button onClick={() => setShowRenameModal(null)} style={styles.buttonSecondary}>Cancel</button><button onClick={() => void handleRename()} style={{ ...styles.button, ...(!renameValue ? styles.buttonDisabled : {}) }} disabled={!renameValue || loading}>{loading ? 'Renaming...' : 'Rename'}</button></div>
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
</>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// =============================================================================
|
|
553
|
+
// FILES TAB
|
|
554
|
+
// =============================================================================
|
|
555
|
+
|
|
556
|
+
function FilesTab({ darkMode, colors, styles }: { darkMode: boolean; colors: Record<string, string>; styles: Record<string, CSSProperties> }): ReactElement {
|
|
557
|
+
const [files, setFiles] = useState<FileEntry[]>([]);
|
|
558
|
+
const [currentPath, setCurrentPath] = useState('/');
|
|
559
|
+
const [loading, setLoading] = useState(false);
|
|
560
|
+
const [error, setError] = useState<string | null>(null);
|
|
561
|
+
const [notImplemented, setNotImplemented] = useState(false);
|
|
562
|
+
const [selectedFile, setSelectedFile] = useState<FileEntry | null>(null);
|
|
563
|
+
const [showCreateDirModal, setShowCreateDirModal] = useState(false);
|
|
564
|
+
const [newDirName, setNewDirName] = useState('');
|
|
565
|
+
|
|
566
|
+
// Ref guard to prevent double-loads in StrictMode
|
|
567
|
+
const didLoadRef = useRef(false);
|
|
568
|
+
|
|
569
|
+
const loadFiles = useCallback(async (path: string = '/') => {
|
|
570
|
+
setLoading(true);
|
|
571
|
+
setError(null);
|
|
572
|
+
try {
|
|
573
|
+
const result = await ekka.vault.files.list(path);
|
|
574
|
+
setFiles(result);
|
|
575
|
+
setCurrentPath(path);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
if (err instanceof Error && (err.message.includes('not implemented') || err.message.includes('op_unknown'))) {
|
|
578
|
+
setNotImplemented(true);
|
|
579
|
+
} else {
|
|
580
|
+
setError(err instanceof Error ? err.message : 'Failed to load');
|
|
581
|
+
}
|
|
582
|
+
} finally {
|
|
583
|
+
setLoading(false);
|
|
584
|
+
}
|
|
585
|
+
}, []);
|
|
586
|
+
|
|
587
|
+
// Load files only once on mount
|
|
588
|
+
useEffect(() => {
|
|
589
|
+
if (didLoadRef.current) return;
|
|
590
|
+
didLoadRef.current = true;
|
|
591
|
+
void loadFiles('/');
|
|
592
|
+
}, [loadFiles]);
|
|
593
|
+
|
|
594
|
+
const handleNavigate = (path: string) => {
|
|
595
|
+
setSelectedFile(null);
|
|
596
|
+
void loadFiles(path);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const handleCreateDir = async () => {
|
|
600
|
+
if (!newDirName.trim()) return;
|
|
601
|
+
setLoading(true);
|
|
602
|
+
try {
|
|
603
|
+
const path = currentPath === '/' ? `/${newDirName}` : `${currentPath}/${newDirName}`;
|
|
604
|
+
await ekka.vault.files.mkdir(path);
|
|
605
|
+
setShowCreateDirModal(false);
|
|
606
|
+
setNewDirName('');
|
|
607
|
+
void loadFiles(currentPath);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
setError(err instanceof Error ? err.message : 'Failed to create directory');
|
|
610
|
+
} finally {
|
|
611
|
+
setLoading(false);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const handleDelete = async (file: FileEntry) => {
|
|
616
|
+
const msg = file.kind === 'DIR' ? 'Delete this directory and all its contents?' : 'Delete this file?';
|
|
617
|
+
if (!confirm(msg)) return;
|
|
618
|
+
try {
|
|
619
|
+
await ekka.vault.files.delete(file.path, { recursive: file.kind === 'DIR' });
|
|
620
|
+
if (selectedFile?.path === file.path) setSelectedFile(null);
|
|
621
|
+
void loadFiles(currentPath);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
setError(err instanceof Error ? err.message : 'Failed to delete');
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const getParentPath = (path: string): string => {
|
|
628
|
+
if (path === '/') return '/';
|
|
629
|
+
const parts = path.split('/').filter(Boolean);
|
|
630
|
+
parts.pop();
|
|
631
|
+
return parts.length === 0 ? '/' : '/' + parts.join('/');
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
if (notImplemented) return <div style={styles.notImplementedCard}><div style={styles.notImplementedTitle}>Backend Not Implemented</div><p style={styles.notImplementedText}>The files backend is not yet available.</p></div>;
|
|
635
|
+
|
|
636
|
+
const dirs = files.filter(f => f.kind === 'DIR');
|
|
637
|
+
const regularFiles = files.filter(f => f.kind === 'FILE');
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<>
|
|
641
|
+
{error && <div style={{ marginBottom: '16px' }}><Banner type="error" message={error} darkMode={darkMode} /></div>}
|
|
642
|
+
<div style={styles.toolbar}>
|
|
643
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
644
|
+
<button onClick={() => handleNavigate(getParentPath(currentPath))} style={styles.buttonSecondary} disabled={currentPath === '/' || loading}>↑ Up</button>
|
|
645
|
+
<span style={{ fontSize: '13px', fontFamily: 'SF Mono, Monaco, Consolas, monospace', color: colors.text }}>{currentPath}</span>
|
|
646
|
+
</div>
|
|
647
|
+
<button onClick={() => void loadFiles(currentPath)} style={styles.buttonSecondary} disabled={loading}>Refresh</button>
|
|
648
|
+
<div style={{ flex: 1 }} />
|
|
649
|
+
<button onClick={() => setShowCreateDirModal(true)} style={styles.button}>New Directory</button>
|
|
650
|
+
</div>
|
|
651
|
+
<div style={styles.card}>
|
|
652
|
+
{loading && files.length === 0 ? <div style={styles.loadingOverlay}>Loading...</div> : files.length === 0 ? <EmptyState icon={<FilesIconLarge />} message="Empty directory" hint="Create directories or upload files." darkMode={darkMode} /> : (
|
|
653
|
+
<div style={styles.folderTree}>
|
|
654
|
+
<div style={styles.folderTreeLeft}>
|
|
655
|
+
{dirs.length > 0 && (
|
|
656
|
+
<div style={{ marginBottom: '16px' }}>
|
|
657
|
+
<div style={{ fontSize: '11px', fontWeight: 600, color: colors.textMuted, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px', paddingLeft: '12px' }}>Directories</div>
|
|
658
|
+
{dirs.map(d => (
|
|
659
|
+
<div key={d.path} onDoubleClick={() => handleNavigate(d.path)} onClick={() => setSelectedFile(d)} style={{ ...styles.folderItem, ...(selectedFile?.path === d.path ? styles.folderItemSelected : {}) }}>
|
|
660
|
+
<span style={styles.folderIcon}><FolderIcon /></span>
|
|
661
|
+
<span style={styles.folderName}>{d.name}</span>
|
|
662
|
+
</div>
|
|
663
|
+
))}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
{regularFiles.length > 0 && (
|
|
667
|
+
<div>
|
|
668
|
+
<div style={{ fontSize: '11px', fontWeight: 600, color: colors.textMuted, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px', paddingLeft: '12px' }}>Files</div>
|
|
669
|
+
{regularFiles.map(f => (
|
|
670
|
+
<div key={f.path} onClick={() => setSelectedFile(f)} style={{ ...styles.folderItem, ...(selectedFile?.path === f.path ? styles.folderItemSelected : {}) }}>
|
|
671
|
+
<span style={{ ...styles.folderIcon, color: colors.textMuted }}>📄</span>
|
|
672
|
+
<span style={styles.folderName}>{f.name}</span>
|
|
673
|
+
<span style={{ fontSize: '11px', color: colors.textDim }}>{formatFileSize(f.sizeBytes)}</span>
|
|
674
|
+
</div>
|
|
675
|
+
))}
|
|
676
|
+
</div>
|
|
677
|
+
)}
|
|
678
|
+
</div>
|
|
679
|
+
<div style={styles.folderTreeRight}>
|
|
680
|
+
{selectedFile ? (
|
|
681
|
+
<div style={styles.detailPanel}>
|
|
682
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
|
683
|
+
<div style={{ fontSize: '15px', fontWeight: 600, color: colors.text }}>{selectedFile.name}</div>
|
|
684
|
+
<button onClick={() => void handleDelete(selectedFile)} style={styles.buttonDanger}>Delete</button>
|
|
685
|
+
</div>
|
|
686
|
+
<div style={styles.detailRow}><span style={styles.detailLabel}>Type</span><span style={styles.detailValue}>{selectedFile.kind === 'DIR' ? 'Directory' : 'File'}</span></div>
|
|
687
|
+
<div style={styles.detailRow}><span style={styles.detailLabel}>Path</span><span style={{ ...styles.detailValue, fontFamily: 'SF Mono, Monaco, Consolas, monospace', fontSize: '12px' }}>{selectedFile.path}</span></div>
|
|
688
|
+
{selectedFile.sizeBytes !== undefined && <div style={styles.detailRow}><span style={styles.detailLabel}>Size</span><span style={styles.detailValue}>{formatFileSize(selectedFile.sizeBytes)}</span></div>}
|
|
689
|
+
{selectedFile.modifiedAt && <div style={styles.detailRow}><span style={styles.detailLabel}>Modified</span><span style={styles.detailValue}>{formatDateTime(selectedFile.modifiedAt)}</span></div>}
|
|
690
|
+
{selectedFile.kind === 'DIR' && <button onClick={() => handleNavigate(selectedFile.path)} style={{ ...styles.button, marginTop: '16px' }}>Open Directory</button>}
|
|
691
|
+
</div>
|
|
692
|
+
) : <div style={styles.detailPanel}><p style={{ fontSize: '13px', color: colors.textMuted, textAlign: 'center' }}>Select a file or directory</p></div>}
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
)}
|
|
696
|
+
</div>
|
|
697
|
+
{showCreateDirModal && (
|
|
698
|
+
<div style={styles.modalOverlay} onClick={() => setShowCreateDirModal(false)}>
|
|
699
|
+
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
|
700
|
+
<h2 style={styles.modalTitle}>Create Directory</h2>
|
|
701
|
+
<div style={styles.inputGroup}><label style={styles.label}>Name</label><input type="text" value={newDirName} onChange={e => setNewDirName(e.target.value)} style={styles.input} placeholder="my-directory" /></div>
|
|
702
|
+
<div style={styles.modalActions}><button onClick={() => setShowCreateDirModal(false)} style={styles.buttonSecondary}>Cancel</button><button onClick={() => void handleCreateDir()} style={{ ...styles.button, ...(!newDirName.trim() ? styles.buttonDisabled : {}) }} disabled={!newDirName.trim() || loading}>{loading ? 'Creating...' : 'Create'}</button></div>
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
</>
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// =============================================================================
|
|
711
|
+
// AUDIT TAB
|
|
712
|
+
// =============================================================================
|
|
713
|
+
|
|
714
|
+
function AuditTab({ darkMode, colors, styles }: { darkMode: boolean; colors: Record<string, string>; styles: Record<string, CSSProperties> }): ReactElement {
|
|
715
|
+
const [entries, setEntries] = useState<AuditEvent[]>([]);
|
|
716
|
+
const [loading, setLoading] = useState(false);
|
|
717
|
+
const [error, setError] = useState<string | null>(null);
|
|
718
|
+
const [notImplemented, setNotImplemented] = useState(false);
|
|
719
|
+
const [hasMore, setHasMore] = useState(false);
|
|
720
|
+
const [search, setSearch] = useState('');
|
|
721
|
+
const [actionFilter, setActionFilter] = useState('');
|
|
722
|
+
|
|
723
|
+
// Ref guard to prevent double-loads in StrictMode
|
|
724
|
+
const didLoadRef = useRef(false);
|
|
725
|
+
const lastFilterRef = useRef(actionFilter);
|
|
726
|
+
|
|
727
|
+
const loadAudit = useCallback(async () => {
|
|
728
|
+
setLoading(true);
|
|
729
|
+
setError(null);
|
|
730
|
+
try {
|
|
731
|
+
const result = await ekka.vault.audit.list({ limit: 50, action: actionFilter || undefined });
|
|
732
|
+
setEntries(result.events);
|
|
733
|
+
setHasMore(result.hasMore);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
if (err instanceof Error && (err.message.includes('not implemented') || err.message.includes('op_unknown'))) {
|
|
736
|
+
setNotImplemented(true);
|
|
737
|
+
} else {
|
|
738
|
+
setError(err instanceof Error ? err.message : 'Failed to load');
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
setLoading(false);
|
|
742
|
+
}
|
|
743
|
+
}, [actionFilter]);
|
|
744
|
+
|
|
745
|
+
// Load audit only once on mount, or when filter changes
|
|
746
|
+
useEffect(() => {
|
|
747
|
+
// Allow reload if filter changed
|
|
748
|
+
if (didLoadRef.current && lastFilterRef.current === actionFilter) return;
|
|
749
|
+
didLoadRef.current = true;
|
|
750
|
+
lastFilterRef.current = actionFilter;
|
|
751
|
+
void loadAudit();
|
|
752
|
+
}, [loadAudit, actionFilter]);
|
|
753
|
+
|
|
754
|
+
if (notImplemented) return <div style={styles.notImplementedCard}><div style={styles.notImplementedTitle}>Backend Not Implemented</div><p style={styles.notImplementedText}>The audit backend is not yet available.</p></div>;
|
|
755
|
+
|
|
756
|
+
const filtered = search ? entries.filter(e => (e.secretName?.toLowerCase().includes(search.toLowerCase())) || e.action.toLowerCase().includes(search.toLowerCase()) || e.path?.toLowerCase().includes(search.toLowerCase())) : entries;
|
|
757
|
+
|
|
758
|
+
return (
|
|
759
|
+
<>
|
|
760
|
+
{error && <div style={{ marginBottom: '16px' }}><Banner type="error" message={error} darkMode={darkMode} /></div>}
|
|
761
|
+
<div style={styles.toolbar}>
|
|
762
|
+
<input type="text" placeholder="Filter..." value={search} onChange={e => setSearch(e.target.value)} style={styles.searchInput} />
|
|
763
|
+
<select value={actionFilter} onChange={e => setActionFilter(e.target.value)} style={styles.select}>
|
|
764
|
+
<option value="">All events</option>
|
|
765
|
+
<optgroup label="Secrets">
|
|
766
|
+
<option value="secret.created">Secret Created</option>
|
|
767
|
+
<option value="secret.updated">Secret Updated</option>
|
|
768
|
+
<option value="secret.deleted">Secret Deleted</option>
|
|
769
|
+
<option value="secret.accessed">Secret Accessed</option>
|
|
770
|
+
</optgroup>
|
|
771
|
+
<optgroup label="Bundles">
|
|
772
|
+
<option value="bundle.created">Bundle Created</option>
|
|
773
|
+
<option value="bundle.updated">Bundle Updated</option>
|
|
774
|
+
<option value="bundle.deleted">Bundle Deleted</option>
|
|
775
|
+
</optgroup>
|
|
776
|
+
<optgroup label="Files">
|
|
777
|
+
<option value="file.written">File Written</option>
|
|
778
|
+
<option value="file.read">File Read</option>
|
|
779
|
+
<option value="file.deleted">File Deleted</option>
|
|
780
|
+
<option value="file.mkdir">Directory Created</option>
|
|
781
|
+
</optgroup>
|
|
782
|
+
</select>
|
|
783
|
+
<button onClick={() => void loadAudit()} style={styles.buttonSecondary} disabled={loading}>Refresh</button>
|
|
784
|
+
</div>
|
|
785
|
+
<div style={{ ...styles.card, padding: 0, overflowX: 'auto' }}>
|
|
786
|
+
{loading && entries.length === 0 ? <div style={styles.loadingOverlay}>Loading...</div> : filtered.length === 0 ? <EmptyState icon={<AuditIcon />} message="No audit entries" hint="Events will appear as actions are performed." darkMode={darkMode} /> : (
|
|
787
|
+
<table style={styles.table}>
|
|
788
|
+
<thead><tr><th style={styles.tableHeader}>Time</th><th style={styles.tableHeader}>Action</th><th style={styles.tableHeader}>Target</th><th style={styles.tableHeader}>Actor</th></tr></thead>
|
|
789
|
+
<tbody>
|
|
790
|
+
{filtered.map(e => (
|
|
791
|
+
<tr key={e.eventId}>
|
|
792
|
+
<td style={styles.tableCell}>{formatDateTime(e.timestamp)}</td>
|
|
793
|
+
<td style={styles.tableCell}><span style={{ ...styles.badge, ...styles.badgeBlue }}>{e.action.replace(/\./g, ' ')}</span></td>
|
|
794
|
+
<td style={styles.tableCell}>{e.secretName || e.path || e.secretId || <span style={{ color: colors.textDim }}>-</span>}</td>
|
|
795
|
+
<td style={styles.tableCellMono}>{e.actorId || <span style={{ color: colors.textDim }}>system</span>}</td>
|
|
796
|
+
</tr>
|
|
797
|
+
))}
|
|
798
|
+
</tbody>
|
|
799
|
+
</table>
|
|
800
|
+
)}
|
|
801
|
+
</div>
|
|
802
|
+
{hasMore && <div style={styles.pagination}><button onClick={() => void loadAudit()} style={styles.buttonSecondary} disabled={loading}>Load More</button></div>}
|
|
803
|
+
</>
|
|
804
|
+
);
|
|
805
|
+
}
|