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.
Files changed (96) hide show
  1. package/README.md +137 -0
  2. package/bin/cli.js +72 -0
  3. package/package.json +23 -0
  4. package/template/branding/app.json +6 -0
  5. package/template/branding/icon.icns +0 -0
  6. package/template/eslint.config.js +98 -0
  7. package/template/index.html +29 -0
  8. package/template/package.json +40 -0
  9. package/template/src/app/App.tsx +24 -0
  10. package/template/src/demo/DemoApp.tsx +260 -0
  11. package/template/src/demo/components/Banner.tsx +82 -0
  12. package/template/src/demo/components/EmptyState.tsx +61 -0
  13. package/template/src/demo/components/InfoPopover.tsx +171 -0
  14. package/template/src/demo/components/InfoTooltip.tsx +76 -0
  15. package/template/src/demo/components/LearnMore.tsx +98 -0
  16. package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
  17. package/template/src/demo/components/SetupWizard.tsx +48 -0
  18. package/template/src/demo/components/StatusBadge.tsx +83 -0
  19. package/template/src/demo/components/index.ts +10 -0
  20. package/template/src/demo/hooks/index.ts +6 -0
  21. package/template/src/demo/hooks/useAuditEvents.ts +30 -0
  22. package/template/src/demo/layout/Shell.tsx +110 -0
  23. package/template/src/demo/layout/Sidebar.tsx +192 -0
  24. package/template/src/demo/pages/AuditLogPage.tsx +235 -0
  25. package/template/src/demo/pages/DocGenPage.tsx +874 -0
  26. package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
  27. package/template/src/demo/pages/LoginPage.tsx +192 -0
  28. package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
  29. package/template/src/demo/pages/RunnerPage.tsx +445 -0
  30. package/template/src/demo/pages/SystemPage.tsx +557 -0
  31. package/template/src/demo/pages/VaultPage.tsx +805 -0
  32. package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
  33. package/template/src/ekka/audit/index.ts +7 -0
  34. package/template/src/ekka/audit/store.ts +68 -0
  35. package/template/src/ekka/audit/types.ts +22 -0
  36. package/template/src/ekka/auth/client.ts +212 -0
  37. package/template/src/ekka/auth/index.ts +30 -0
  38. package/template/src/ekka/auth/storage.ts +114 -0
  39. package/template/src/ekka/auth/types.ts +67 -0
  40. package/template/src/ekka/backend/demo.ts +151 -0
  41. package/template/src/ekka/backend/interface.ts +36 -0
  42. package/template/src/ekka/config.ts +48 -0
  43. package/template/src/ekka/constants.ts +143 -0
  44. package/template/src/ekka/errors.ts +54 -0
  45. package/template/src/ekka/index.ts +516 -0
  46. package/template/src/ekka/internal/backend.ts +156 -0
  47. package/template/src/ekka/internal/index.ts +7 -0
  48. package/template/src/ekka/ops/auth.ts +29 -0
  49. package/template/src/ekka/ops/debug.ts +68 -0
  50. package/template/src/ekka/ops/home.ts +101 -0
  51. package/template/src/ekka/ops/index.ts +16 -0
  52. package/template/src/ekka/ops/nodeCredentials.ts +131 -0
  53. package/template/src/ekka/ops/nodeSession.ts +145 -0
  54. package/template/src/ekka/ops/paths.ts +183 -0
  55. package/template/src/ekka/ops/runner.ts +86 -0
  56. package/template/src/ekka/ops/runtime.ts +31 -0
  57. package/template/src/ekka/ops/setup.ts +47 -0
  58. package/template/src/ekka/ops/vault.ts +459 -0
  59. package/template/src/ekka/ops/workflowRuns.ts +116 -0
  60. package/template/src/ekka/types.ts +82 -0
  61. package/template/src/ekka/utils/idempotency.ts +14 -0
  62. package/template/src/ekka/utils/index.ts +7 -0
  63. package/template/src/ekka/utils/time.ts +77 -0
  64. package/template/src/main.tsx +12 -0
  65. package/template/src/vite-env.d.ts +12 -0
  66. package/template/src-tauri/Cargo.toml +41 -0
  67. package/template/src-tauri/build.rs +3 -0
  68. package/template/src-tauri/capabilities/default.json +11 -0
  69. package/template/src-tauri/icons/icon.icns +0 -0
  70. package/template/src-tauri/icons/icon.png +0 -0
  71. package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
  72. package/template/src-tauri/src/bootstrap.rs +37 -0
  73. package/template/src-tauri/src/commands.rs +1215 -0
  74. package/template/src-tauri/src/device_secret.rs +111 -0
  75. package/template/src-tauri/src/engine_process.rs +538 -0
  76. package/template/src-tauri/src/grants.rs +129 -0
  77. package/template/src-tauri/src/handlers/home.rs +65 -0
  78. package/template/src-tauri/src/handlers/mod.rs +7 -0
  79. package/template/src-tauri/src/handlers/paths.rs +128 -0
  80. package/template/src-tauri/src/handlers/vault.rs +680 -0
  81. package/template/src-tauri/src/main.rs +243 -0
  82. package/template/src-tauri/src/node_auth.rs +858 -0
  83. package/template/src-tauri/src/node_credentials.rs +541 -0
  84. package/template/src-tauri/src/node_runner.rs +882 -0
  85. package/template/src-tauri/src/node_vault_crypto.rs +113 -0
  86. package/template/src-tauri/src/node_vault_store.rs +267 -0
  87. package/template/src-tauri/src/ops/auth.rs +50 -0
  88. package/template/src-tauri/src/ops/home.rs +251 -0
  89. package/template/src-tauri/src/ops/mod.rs +7 -0
  90. package/template/src-tauri/src/ops/runtime.rs +21 -0
  91. package/template/src-tauri/src/state.rs +639 -0
  92. package/template/src-tauri/src/types.rs +84 -0
  93. package/template/src-tauri/tauri.conf.json +41 -0
  94. package/template/tsconfig.json +26 -0
  95. package/template/tsconfig.tsbuildinfo +1 -0
  96. package/template/vite.config.ts +34 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Path Permissions Page
3
+ *
4
+ * Demo tab showing how EKKA manages filesystem access using engine-signed grants.
5
+ * NO FILE I/O - only permission management demonstration.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, type CSSProperties, type ReactElement } from 'react';
9
+ import { ekka, advanced, type PathInfo, type PathType, type PathAccess, type PathGrantResult } from '../../ekka';
10
+ import { InfoPopover } from '../components/InfoPopover';
11
+
12
+ interface PathPermissionsPageProps {
13
+ darkMode: boolean;
14
+ }
15
+
16
+ interface PermissionCheck {
17
+ allowed: boolean;
18
+ reason: string;
19
+ pathType: PathType | null;
20
+ access: PathAccess | null;
21
+ grantedBy: string | null;
22
+ }
23
+
24
+ export function PathPermissionsPage({ darkMode }: PathPermissionsPageProps): ReactElement {
25
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
26
+ const [pathInput, setPathInput] = useState('');
27
+ const [permissionStatus, setPermissionStatus] = useState<PermissionCheck | null>(null);
28
+ const [grantResult, setGrantResult] = useState<PathGrantResult | null>(null);
29
+ const [allPaths, setAllPaths] = useState<PathInfo[]>([]);
30
+ const [loading, setLoading] = useState(false);
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [requestedAccess, setRequestedAccess] = useState<PathAccess>('READ_WRITE');
33
+
34
+ const colors = {
35
+ text: darkMode ? '#ffffff' : '#1d1d1f',
36
+ textMuted: darkMode ? '#98989d' : '#6e6e73',
37
+ textDim: darkMode ? '#636366' : '#aeaeb2',
38
+ bg: darkMode ? '#2c2c2e' : '#fafafa',
39
+ bgAlt: darkMode ? '#1c1c1e' : '#ffffff',
40
+ bgInput: darkMode ? '#3a3a3c' : '#ffffff',
41
+ border: darkMode ? '#3a3a3c' : '#e5e5e5',
42
+ accent: darkMode ? '#0a84ff' : '#007aff',
43
+ green: darkMode ? '#30d158' : '#34c759',
44
+ orange: darkMode ? '#ff9f0a' : '#ff9500',
45
+ red: darkMode ? '#ff453a' : '#ff3b30',
46
+ purple: darkMode ? '#bf5af2' : '#af52de',
47
+ };
48
+
49
+ const styles: Record<string, CSSProperties> = {
50
+ container: {
51
+ width: '100%',
52
+ },
53
+ header: {
54
+ marginBottom: '32px',
55
+ },
56
+ title: {
57
+ fontSize: '28px',
58
+ fontWeight: 700,
59
+ color: colors.text,
60
+ marginBottom: '8px',
61
+ letterSpacing: '-0.02em',
62
+ },
63
+ subtitle: {
64
+ fontSize: '14px',
65
+ color: colors.textMuted,
66
+ lineHeight: 1.6,
67
+ maxWidth: '600px',
68
+ },
69
+ section: {
70
+ marginBottom: '28px',
71
+ },
72
+ sectionHeader: {
73
+ display: 'flex',
74
+ alignItems: 'center',
75
+ gap: '8px',
76
+ marginBottom: '12px',
77
+ },
78
+ sectionTitle: {
79
+ fontSize: '11px',
80
+ fontWeight: 600,
81
+ color: colors.textMuted,
82
+ textTransform: 'uppercase' as const,
83
+ letterSpacing: '0.05em',
84
+ },
85
+ sectionLine: {
86
+ flex: 1,
87
+ height: '1px',
88
+ background: colors.border,
89
+ },
90
+ card: {
91
+ background: colors.bg,
92
+ border: `1px solid ${colors.border}`,
93
+ borderRadius: '12px',
94
+ padding: '20px',
95
+ },
96
+ pathSelector: {
97
+ display: 'flex',
98
+ gap: '12px',
99
+ alignItems: 'flex-start',
100
+ flexWrap: 'wrap' as const,
101
+ },
102
+ input: {
103
+ flex: '1 1 300px',
104
+ minWidth: '200px',
105
+ padding: '10px 14px',
106
+ fontSize: '13px',
107
+ fontFamily: 'SF Mono, Monaco, Consolas, monospace',
108
+ background: colors.bgInput,
109
+ border: `1px solid ${colors.border}`,
110
+ borderRadius: '8px',
111
+ color: colors.text,
112
+ outline: 'none',
113
+ },
114
+ button: {
115
+ padding: '10px 20px',
116
+ fontSize: '13px',
117
+ fontWeight: 600,
118
+ color: '#ffffff',
119
+ background: colors.accent,
120
+ border: 'none',
121
+ borderRadius: '8px',
122
+ cursor: 'pointer',
123
+ transition: 'opacity 0.15s ease',
124
+ whiteSpace: 'nowrap' as const,
125
+ },
126
+ buttonSecondary: {
127
+ padding: '10px 20px',
128
+ fontSize: '13px',
129
+ fontWeight: 600,
130
+ color: colors.accent,
131
+ background: darkMode ? 'rgba(10, 132, 255, 0.15)' : 'rgba(0, 122, 255, 0.1)',
132
+ border: 'none',
133
+ borderRadius: '8px',
134
+ cursor: 'pointer',
135
+ transition: 'opacity 0.15s ease',
136
+ whiteSpace: 'nowrap' as const,
137
+ },
138
+ buttonDanger: {
139
+ padding: '10px 20px',
140
+ fontSize: '13px',
141
+ fontWeight: 600,
142
+ color: colors.red,
143
+ background: darkMode ? 'rgba(255, 69, 58, 0.15)' : 'rgba(255, 59, 48, 0.1)',
144
+ border: 'none',
145
+ borderRadius: '8px',
146
+ cursor: 'pointer',
147
+ transition: 'opacity 0.15s ease',
148
+ whiteSpace: 'nowrap' as const,
149
+ },
150
+ buttonDisabled: {
151
+ opacity: 0.5,
152
+ cursor: 'not-allowed',
153
+ },
154
+ selectedPath: {
155
+ marginTop: '16px',
156
+ padding: '14px 16px',
157
+ background: darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.02)',
158
+ borderRadius: '8px',
159
+ },
160
+ selectedPathLabel: {
161
+ fontSize: '11px',
162
+ fontWeight: 600,
163
+ color: colors.textMuted,
164
+ textTransform: 'uppercase' as const,
165
+ letterSpacing: '0.04em',
166
+ marginBottom: '6px',
167
+ },
168
+ selectedPathValue: {
169
+ fontSize: '13px',
170
+ fontFamily: 'SF Mono, Monaco, Consolas, monospace',
171
+ color: colors.text,
172
+ wordBreak: 'break-all' as const,
173
+ },
174
+ statusCard: {
175
+ display: 'flex',
176
+ alignItems: 'center',
177
+ gap: '16px',
178
+ padding: '20px',
179
+ background: colors.bg,
180
+ border: `1px solid ${colors.border}`,
181
+ borderRadius: '12px',
182
+ },
183
+ statusIcon: {
184
+ width: '48px',
185
+ height: '48px',
186
+ borderRadius: '12px',
187
+ display: 'flex',
188
+ alignItems: 'center',
189
+ justifyContent: 'center',
190
+ flexShrink: 0,
191
+ },
192
+ statusContent: {
193
+ flex: 1,
194
+ },
195
+ statusTitle: {
196
+ fontSize: '16px',
197
+ fontWeight: 600,
198
+ color: colors.text,
199
+ marginBottom: '4px',
200
+ },
201
+ statusReason: {
202
+ fontSize: '13px',
203
+ color: colors.textMuted,
204
+ },
205
+ grantPanel: {
206
+ marginTop: '20px',
207
+ padding: '20px',
208
+ background: darkMode ? 'rgba(48, 209, 88, 0.08)' : 'rgba(52, 199, 89, 0.06)',
209
+ borderRadius: '12px',
210
+ border: `1px solid ${darkMode ? 'rgba(48, 209, 88, 0.2)' : 'rgba(52, 199, 89, 0.15)'}`,
211
+ },
212
+ grantTitle: {
213
+ fontSize: '15px',
214
+ fontWeight: 600,
215
+ color: colors.green,
216
+ marginBottom: '12px',
217
+ display: 'flex',
218
+ alignItems: 'center',
219
+ gap: '8px',
220
+ },
221
+ grantDetails: {
222
+ display: 'grid',
223
+ gap: '8px',
224
+ },
225
+ grantRow: {
226
+ display: 'flex',
227
+ gap: '12px',
228
+ fontSize: '13px',
229
+ },
230
+ grantLabel: {
231
+ color: colors.textMuted,
232
+ minWidth: '100px',
233
+ },
234
+ grantValue: {
235
+ color: colors.text,
236
+ fontFamily: 'SF Mono, Monaco, Consolas, monospace',
237
+ wordBreak: 'break-all' as const,
238
+ },
239
+ table: {
240
+ width: '100%',
241
+ minWidth: '600px',
242
+ borderCollapse: 'collapse' as const,
243
+ },
244
+ tableHeader: {
245
+ textAlign: 'left' as const,
246
+ padding: '12px 16px',
247
+ fontSize: '11px',
248
+ fontWeight: 600,
249
+ color: colors.textMuted,
250
+ textTransform: 'uppercase' as const,
251
+ letterSpacing: '0.04em',
252
+ borderBottom: `1px solid ${colors.border}`,
253
+ },
254
+ tableCell: {
255
+ padding: '14px 16px',
256
+ fontSize: '13px',
257
+ color: colors.text,
258
+ borderBottom: `1px solid ${colors.border}`,
259
+ },
260
+ tableCellMono: {
261
+ padding: '14px 16px',
262
+ fontSize: '12px',
263
+ fontFamily: 'SF Mono, Monaco, Consolas, monospace',
264
+ color: colors.text,
265
+ borderBottom: `1px solid ${colors.border}`,
266
+ maxWidth: '300px',
267
+ overflow: 'hidden',
268
+ textOverflow: 'ellipsis',
269
+ whiteSpace: 'nowrap' as const,
270
+ },
271
+ badge: {
272
+ display: 'inline-flex',
273
+ alignItems: 'center',
274
+ gap: '6px',
275
+ padding: '4px 10px',
276
+ borderRadius: '6px',
277
+ fontSize: '11px',
278
+ fontWeight: 600,
279
+ },
280
+ badgeGreen: {
281
+ background: darkMode ? 'rgba(48, 209, 88, 0.15)' : 'rgba(52, 199, 89, 0.12)',
282
+ color: colors.green,
283
+ },
284
+ badgeBlue: {
285
+ background: darkMode ? 'rgba(10, 132, 255, 0.15)' : 'rgba(0, 122, 255, 0.12)',
286
+ color: colors.accent,
287
+ },
288
+ badgeOrange: {
289
+ background: darkMode ? 'rgba(255, 159, 10, 0.15)' : 'rgba(255, 149, 0, 0.12)',
290
+ color: colors.orange,
291
+ },
292
+ emptyState: {
293
+ textAlign: 'center' as const,
294
+ padding: '40px 20px',
295
+ color: colors.textMuted,
296
+ fontSize: '14px',
297
+ },
298
+ error: {
299
+ marginBottom: '20px',
300
+ padding: '12px 14px',
301
+ background: darkMode ? '#3c1618' : '#fef2f2',
302
+ border: `1px solid ${darkMode ? '#7f1d1d' : '#fecaca'}`,
303
+ borderRadius: '8px',
304
+ fontSize: '13px',
305
+ color: darkMode ? '#fca5a5' : '#991b1b',
306
+ },
307
+ actions: {
308
+ display: 'flex',
309
+ gap: '12px',
310
+ marginTop: '20px',
311
+ flexWrap: 'wrap' as const,
312
+ alignItems: 'center',
313
+ },
314
+ accessSelector: {
315
+ display: 'flex',
316
+ gap: '8px',
317
+ padding: '16px',
318
+ marginTop: '16px',
319
+ background: darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.02)',
320
+ borderRadius: '8px',
321
+ },
322
+ accessOption: {
323
+ display: 'flex',
324
+ alignItems: 'center',
325
+ gap: '8px',
326
+ padding: '10px 16px',
327
+ background: 'transparent',
328
+ border: `1px solid ${colors.border}`,
329
+ borderRadius: '8px',
330
+ cursor: 'pointer',
331
+ fontSize: '13px',
332
+ fontWeight: 500,
333
+ color: colors.text,
334
+ transition: 'all 0.15s ease',
335
+ },
336
+ accessOptionSelected: {
337
+ background: colors.accent,
338
+ borderColor: colors.accent,
339
+ color: '#ffffff',
340
+ },
341
+ tableDangerButton: {
342
+ display: 'flex',
343
+ alignItems: 'center',
344
+ justifyContent: 'center',
345
+ width: '32px',
346
+ height: '32px',
347
+ padding: 0,
348
+ background: 'transparent',
349
+ border: 'none',
350
+ borderRadius: '6px',
351
+ cursor: 'pointer',
352
+ color: colors.red,
353
+ opacity: 0.7,
354
+ transition: 'opacity 0.15s ease, background 0.15s ease',
355
+ },
356
+ };
357
+
358
+ // Load all paths on mount and after changes
359
+ const loadPaths = useCallback(async () => {
360
+ try {
361
+ const paths = await ekka.paths.list();
362
+ setAllPaths(paths);
363
+ } catch {
364
+ // Ignore errors when loading paths
365
+ }
366
+ }, []);
367
+
368
+ useEffect(() => {
369
+ void loadPaths();
370
+ }, [loadPaths]);
371
+
372
+ // Check permission status when path is selected
373
+ const checkPermission = useCallback(async (path: string) => {
374
+ setLoading(true);
375
+ setError(null);
376
+ setGrantResult(null);
377
+
378
+ try {
379
+ // Use advanced API to get detailed check result
380
+ const result = await advanced.paths.check(path, 'read');
381
+ setPermissionStatus(result);
382
+ } catch (err) {
383
+ const message = err instanceof Error ? err.message : 'Failed to check permission';
384
+ setError(message);
385
+ setPermissionStatus(null);
386
+ } finally {
387
+ setLoading(false);
388
+ }
389
+ }, []);
390
+
391
+ // Handle folder selection
392
+ const handleBrowseFolder = async () => {
393
+ setError(null);
394
+ try {
395
+ const { open } = await import('@tauri-apps/plugin-dialog');
396
+ const selected = await open({ directory: true, multiple: false });
397
+ if (selected && typeof selected === 'string') {
398
+ setSelectedPath(selected);
399
+ setPathInput(selected);
400
+ await checkPermission(selected);
401
+ }
402
+ } catch (err) {
403
+ const message = err instanceof Error ? err.message : String(err);
404
+ setError(`Dialog error: ${message}`);
405
+ }
406
+ };
407
+
408
+ // Handle file selection
409
+ const handleBrowseFile = async () => {
410
+ setError(null);
411
+ try {
412
+ const { open } = await import('@tauri-apps/plugin-dialog');
413
+ const selected = await open({ directory: false, multiple: false });
414
+ if (selected && typeof selected === 'string') {
415
+ setSelectedPath(selected);
416
+ setPathInput(selected);
417
+ await checkPermission(selected);
418
+ }
419
+ } catch (err) {
420
+ const message = err instanceof Error ? err.message : String(err);
421
+ setError(`Dialog error: ${message}`);
422
+ }
423
+ };
424
+
425
+ // Handle manual path entry
426
+ const handlePathSubmit = async () => {
427
+ if (pathInput.trim()) {
428
+ setSelectedPath(pathInput.trim());
429
+ await checkPermission(pathInput.trim());
430
+ }
431
+ };
432
+
433
+ // Request permission
434
+ const handleRequestPermission = async () => {
435
+ if (!selectedPath) return;
436
+
437
+ setLoading(true);
438
+ setError(null);
439
+
440
+ try {
441
+ const result = await ekka.paths.allow(selectedPath, {
442
+ pathType: 'WORKSPACE',
443
+ access: requestedAccess,
444
+ });
445
+
446
+ setGrantResult(result);
447
+
448
+ if (result.success) {
449
+ // Re-check permission status
450
+ await checkPermission(selectedPath);
451
+ // Refresh paths list
452
+ await loadPaths();
453
+ } else if (result.error) {
454
+ setError(result.error);
455
+ }
456
+ } catch (err) {
457
+ const message = err instanceof Error ? err.message : 'Failed to request permission';
458
+ setError(message);
459
+ } finally {
460
+ setLoading(false);
461
+ }
462
+ };
463
+
464
+ // Revoke permission for selected path (uses the granting path, not selected path)
465
+ const handleRevokePermission = async () => {
466
+ const pathToRevoke = permissionStatus?.grantedBy || selectedPath;
467
+ if (!pathToRevoke) return;
468
+ await handleRevokeByPath(pathToRevoke);
469
+ };
470
+
471
+ // Revoke permission by path
472
+ const handleRevokeByPath = async (path: string) => {
473
+ setLoading(true);
474
+ setError(null);
475
+ setGrantResult(null);
476
+
477
+ try {
478
+ await ekka.paths.remove(path);
479
+ // Re-check permission status if we have a selected path
480
+ if (selectedPath) {
481
+ await checkPermission(selectedPath);
482
+ }
483
+ // Refresh paths list
484
+ await loadPaths();
485
+ } catch (err) {
486
+ const message = err instanceof Error ? err.message : 'Failed to revoke permission';
487
+ setError(message);
488
+ } finally {
489
+ setLoading(false);
490
+ }
491
+ };
492
+
493
+ const formatPathType = (type: PathType): string => {
494
+ return type.replace(/_/g, ' ');
495
+ };
496
+
497
+ const formatAccess = (access: PathAccess): string => {
498
+ return access === 'READ_WRITE' ? 'Read/Write' : 'Read Only';
499
+ };
500
+
501
+ const formatDate = (dateStr: string): string => {
502
+ try {
503
+ const date = new Date(dateStr);
504
+ return date.toLocaleDateString(undefined, {
505
+ year: 'numeric',
506
+ month: 'short',
507
+ day: 'numeric',
508
+ hour: '2-digit',
509
+ minute: '2-digit',
510
+ });
511
+ } catch {
512
+ return dateStr;
513
+ }
514
+ };
515
+
516
+ return (
517
+ <div style={styles.container}>
518
+ <header style={styles.header}>
519
+ <h1 style={styles.title}>Path Permissions</h1>
520
+ <p style={styles.subtitle}>
521
+ EKKA requires explicit permission to access any path outside your home directory.
522
+ When you approve, EKKA requests an engine-signed grant that unlocks access.
523
+ </p>
524
+ </header>
525
+
526
+ {error && <div style={styles.error}>{error}</div>}
527
+
528
+ {/* Section A: Path Selector */}
529
+ <div style={styles.section}>
530
+ <div style={styles.sectionHeader}>
531
+ <span style={styles.sectionTitle}>Select Path</span>
532
+ <div style={styles.sectionLine} />
533
+ </div>
534
+ <div style={styles.card}>
535
+ <div style={styles.pathSelector}>
536
+ <input
537
+ type="text"
538
+ value={pathInput}
539
+ onChange={(e) => setPathInput(e.target.value)}
540
+ onKeyDown={(e) => {
541
+ if (e.key === 'Enter') void handlePathSubmit();
542
+ }}
543
+ placeholder="/path/to/directory"
544
+ style={styles.input}
545
+ />
546
+ <button
547
+ onClick={() => void handleBrowseFolder()}
548
+ style={styles.buttonSecondary}
549
+ disabled={loading}
550
+ title="Select folder"
551
+ >
552
+ 📁
553
+ </button>
554
+ <button
555
+ onClick={() => void handleBrowseFile()}
556
+ style={styles.buttonSecondary}
557
+ disabled={loading}
558
+ title="Select file"
559
+ >
560
+ 📄
561
+ </button>
562
+ <button
563
+ onClick={() => void handlePathSubmit()}
564
+ style={{
565
+ ...styles.button,
566
+ ...(loading || !pathInput.trim() ? styles.buttonDisabled : {}),
567
+ }}
568
+ disabled={loading || !pathInput.trim()}
569
+ >
570
+ Check Path
571
+ </button>
572
+ </div>
573
+
574
+ </div>
575
+ </div>
576
+
577
+ {/* Section B: Permission Status */}
578
+ {selectedPath && permissionStatus && (
579
+ <div style={styles.section}>
580
+ <div style={styles.sectionHeader}>
581
+ <span style={styles.sectionTitle}>Permission Status</span>
582
+ <div style={styles.sectionLine} />
583
+ </div>
584
+ <div
585
+ style={{
586
+ ...styles.statusCard,
587
+ background: permissionStatus.allowed
588
+ ? darkMode
589
+ ? 'rgba(48, 209, 88, 0.08)'
590
+ : 'rgba(52, 199, 89, 0.06)'
591
+ : darkMode
592
+ ? 'rgba(255, 69, 58, 0.08)'
593
+ : 'rgba(255, 59, 48, 0.06)',
594
+ borderColor: permissionStatus.allowed
595
+ ? darkMode
596
+ ? 'rgba(48, 209, 88, 0.2)'
597
+ : 'rgba(52, 199, 89, 0.15)'
598
+ : darkMode
599
+ ? 'rgba(255, 69, 58, 0.2)'
600
+ : 'rgba(255, 59, 48, 0.15)',
601
+ }}
602
+ >
603
+ <div
604
+ style={{
605
+ ...styles.statusIcon,
606
+ background: permissionStatus.allowed
607
+ ? darkMode
608
+ ? 'rgba(48, 209, 88, 0.15)'
609
+ : 'rgba(52, 199, 89, 0.12)'
610
+ : darkMode
611
+ ? 'rgba(255, 69, 58, 0.15)'
612
+ : 'rgba(255, 59, 48, 0.12)',
613
+ color: permissionStatus.allowed ? colors.green : colors.red,
614
+ }}
615
+ >
616
+ {permissionStatus.allowed ? <CheckIcon /> : <DeniedIcon />}
617
+ </div>
618
+ <div style={styles.statusContent}>
619
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
620
+ <div style={styles.statusTitle}>
621
+ {permissionStatus.allowed ? 'Access Allowed' : 'Access Denied'}
622
+ </div>
623
+ {permissionStatus.allowed && (
624
+ <InfoPopover
625
+ darkMode={darkMode}
626
+ items={[
627
+ { label: 'Grant Path', value: permissionStatus.grantedBy || selectedPath || '-', mono: true },
628
+ { label: 'Selected Path', value: selectedPath || '-', mono: true },
629
+ { label: 'Type', value: permissionStatus.pathType ? formatPathType(permissionStatus.pathType) : '-' },
630
+ { label: 'Access', value: permissionStatus.access ? formatAccess(permissionStatus.access) : '-' },
631
+ ]}
632
+ />
633
+ )}
634
+ </div>
635
+ {permissionStatus.allowed && permissionStatus.pathType && (
636
+ <div style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
637
+ <span style={{ ...styles.badge, ...styles.badgeBlue }}>
638
+ {formatPathType(permissionStatus.pathType)}
639
+ </span>
640
+ {permissionStatus.access && (
641
+ <span style={{ ...styles.badge, ...styles.badgeGreen }}>
642
+ {formatAccess(permissionStatus.access)}
643
+ </span>
644
+ )}
645
+ </div>
646
+ )}
647
+ </div>
648
+ </div>
649
+
650
+ {/* Section C: Access Selection & Grant */}
651
+ {!permissionStatus.allowed && (
652
+ <div style={styles.accessSelector}>
653
+ <span style={{ fontSize: '13px', color: colors.textMuted, marginRight: '8px' }}>
654
+ Access level:
655
+ </span>
656
+ <button
657
+ onClick={() => setRequestedAccess('READ_ONLY')}
658
+ style={{
659
+ ...styles.accessOption,
660
+ ...(requestedAccess === 'READ_ONLY' ? styles.accessOptionSelected : {}),
661
+ }}
662
+ >
663
+ <ReadIcon />
664
+ Read Only
665
+ </button>
666
+ <button
667
+ onClick={() => setRequestedAccess('READ_WRITE')}
668
+ style={{
669
+ ...styles.accessOption,
670
+ ...(requestedAccess === 'READ_WRITE' ? styles.accessOptionSelected : {}),
671
+ }}
672
+ >
673
+ <WriteIcon />
674
+ Read/Write
675
+ </button>
676
+ </div>
677
+ )}
678
+
679
+ {/* Section D: Grant/Revoke Actions */}
680
+ <div style={styles.actions}>
681
+ {!permissionStatus.allowed && (
682
+ <button
683
+ onClick={() => void handleRequestPermission()}
684
+ style={{
685
+ ...styles.button,
686
+ ...(loading ? styles.buttonDisabled : {}),
687
+ }}
688
+ disabled={loading}
689
+ >
690
+ {loading ? 'Requesting...' : `Grant ${requestedAccess === 'READ_WRITE' ? 'Read/Write' : 'Read Only'} Access`}
691
+ </button>
692
+ )}
693
+ {permissionStatus.allowed && (
694
+ <button
695
+ onClick={() => void handleRevokePermission()}
696
+ style={{
697
+ ...styles.buttonDanger,
698
+ ...(loading ? styles.buttonDisabled : {}),
699
+ }}
700
+ disabled={loading}
701
+ >
702
+ {loading ? 'Revoking...' : 'Revoke Permission'}
703
+ </button>
704
+ )}
705
+ </div>
706
+
707
+ {/* Grant Success Panel */}
708
+ {grantResult?.success && (
709
+ <div style={styles.grantPanel}>
710
+ <div style={styles.grantTitle}>
711
+ <CheckIcon />
712
+ Permission Granted
713
+ </div>
714
+ <div style={styles.grantDetails}>
715
+ <div style={styles.grantRow}>
716
+ <span style={styles.grantLabel}>Path</span>
717
+ <span style={styles.grantValue}>{selectedPath}</span>
718
+ </div>
719
+ <div style={styles.grantRow}>
720
+ <span style={styles.grantLabel}>Type</span>
721
+ <span style={styles.grantValue}>WORKSPACE</span>
722
+ </div>
723
+ <div style={styles.grantRow}>
724
+ <span style={styles.grantLabel}>Access</span>
725
+ <span style={styles.grantValue}>{requestedAccess}</span>
726
+ </div>
727
+ {grantResult.grantId && (
728
+ <div style={styles.grantRow}>
729
+ <span style={styles.grantLabel}>Grant ID</span>
730
+ <span style={styles.grantValue}>{grantResult.grantId}</span>
731
+ </div>
732
+ )}
733
+ <div style={styles.grantRow}>
734
+ <span style={styles.grantLabel}>Signed by</span>
735
+ <span style={styles.grantValue}>EKKA Engine</span>
736
+ </div>
737
+ </div>
738
+ </div>
739
+ )}
740
+ </div>
741
+ )}
742
+
743
+ {/* Section E: Current Permissions Table */}
744
+ <div style={styles.section}>
745
+ <div style={styles.sectionHeader}>
746
+ <span style={styles.sectionTitle}>Current Permissions</span>
747
+ <div style={styles.sectionLine} />
748
+ </div>
749
+ <div style={{ ...styles.card, padding: 0, overflowX: 'auto' }}>
750
+ {allPaths.length === 0 ? (
751
+ <div style={styles.emptyState}>
752
+ No path permissions granted yet.
753
+ <br />
754
+ Select a path above to request access.
755
+ </div>
756
+ ) : (
757
+ <table style={styles.table}>
758
+ <thead>
759
+ <tr>
760
+ <th style={styles.tableHeader}>Path Prefix</th>
761
+ <th style={styles.tableHeader}>Type</th>
762
+ <th style={styles.tableHeader}>Access</th>
763
+ <th style={styles.tableHeader}>Status</th>
764
+ <th style={{ ...styles.tableHeader, width: '80px', textAlign: 'right' }}>Actions</th>
765
+ </tr>
766
+ </thead>
767
+ <tbody>
768
+ {allPaths.map((path) => (
769
+ <tr key={path.grantId}>
770
+ <td style={styles.tableCellMono} title={path.path}>
771
+ {path.path}
772
+ </td>
773
+ <td style={styles.tableCell}>
774
+ <span style={{ ...styles.badge, ...styles.badgeBlue }}>
775
+ {formatPathType(path.pathType)}
776
+ </span>
777
+ </td>
778
+ <td style={styles.tableCell}>
779
+ <span style={{ ...styles.badge, ...styles.badgeGreen }}>
780
+ {formatAccess(path.access)}
781
+ </span>
782
+ </td>
783
+ <td style={styles.tableCell}>
784
+ <span
785
+ style={{
786
+ ...styles.badge,
787
+ ...(path.isValid ? styles.badgeGreen : styles.badgeOrange),
788
+ }}
789
+ >
790
+ {path.isValid ? 'Valid' : 'Expired'}
791
+ </span>
792
+ </td>
793
+ <td style={styles.tableCell}>
794
+ <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
795
+ <InfoPopover
796
+ darkMode={darkMode}
797
+ items={[
798
+ { label: 'Grant ID', value: path.grantId, mono: true },
799
+ { label: 'Path', value: path.path, mono: true },
800
+ { label: 'Type', value: formatPathType(path.pathType) },
801
+ { label: 'Access', value: formatAccess(path.access) },
802
+ { label: 'Issuer', value: path.issuer },
803
+ { label: 'Issued At', value: formatDate(path.issuedAt) },
804
+ { label: 'Expires At', value: path.expiresAt ? formatDate(path.expiresAt) : 'Never' },
805
+ { label: 'User', value: path.subject },
806
+ { label: 'Tenant', value: path.tenantId },
807
+ { label: 'Purpose', value: path.purpose },
808
+ ]}
809
+ />
810
+ <button
811
+ onClick={() => void handleRevokeByPath(path.path)}
812
+ style={styles.tableDangerButton}
813
+ disabled={loading}
814
+ title="Revoke this permission"
815
+ >
816
+ <TrashIcon />
817
+ </button>
818
+ </div>
819
+ </td>
820
+ </tr>
821
+ ))}
822
+ </tbody>
823
+ </table>
824
+ )}
825
+ </div>
826
+ </div>
827
+ </div>
828
+ );
829
+ }
830
+
831
+ function CheckIcon(): ReactElement {
832
+ return (
833
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
834
+ <path d="M20 6L9 17l-5-5" />
835
+ </svg>
836
+ );
837
+ }
838
+
839
+ function DeniedIcon(): ReactElement {
840
+ return (
841
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
842
+ <circle cx="12" cy="12" r="10" />
843
+ <path d="M15 9l-6 6M9 9l6 6" />
844
+ </svg>
845
+ );
846
+ }
847
+
848
+ function ReadIcon(): ReactElement {
849
+ return (
850
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
851
+ <path d="M8 3.5a4.5 4.5 0 0 0-4.041 2.5h-.709a5.5 5.5 0 0 1 9.5 0h-.709A4.5 4.5 0 0 0 8 3.5z" />
852
+ <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0z" />
853
+ <path d="M1.323 8.5l-.5-.866C1.89 6.482 4.2 4.5 8 4.5c3.8 0 6.11 1.982 7.177 3.134l-.5.866C13.69 7.475 11.541 5.5 8 5.5 4.459 5.5 2.31 7.475 1.323 8.5z" />
854
+ </svg>
855
+ );
856
+ }
857
+
858
+ function WriteIcon(): ReactElement {
859
+ return (
860
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
861
+ <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" />
862
+ </svg>
863
+ );
864
+ }
865
+
866
+ function TrashIcon(): ReactElement {
867
+ return (
868
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
869
+ <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" />
870
+ <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" />
871
+ </svg>
872
+ );
873
+ }