@accounter/scraper-app 0.0.1

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 +90 -0
  2. package/docs/plan.md +76 -0
  3. package/index.html +12 -0
  4. package/package.json +40 -0
  5. package/src/env.template +2 -0
  6. package/src/server/__tests__/accounts-routes.test.ts +133 -0
  7. package/src/server/__tests__/check-accounts.test.ts +305 -0
  8. package/src/server/__tests__/filter-payload.test.ts +193 -0
  9. package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
  10. package/src/server/__tests__/graphql-client.test.ts +508 -0
  11. package/src/server/__tests__/healthz.test.ts +22 -0
  12. package/src/server/__tests__/history.test.ts +111 -0
  13. package/src/server/__tests__/otp-manager.test.ts +132 -0
  14. package/src/server/__tests__/scrape-runner.test.ts +144 -0
  15. package/src/server/__tests__/settings-routes.test.ts +117 -0
  16. package/src/server/__tests__/sources-routes.test.ts +149 -0
  17. package/src/server/__tests__/validate-payload.test.ts +193 -0
  18. package/src/server/__tests__/vault-routes.test.ts +174 -0
  19. package/src/server/__tests__/vault.test.ts +33 -0
  20. package/src/server/__tests__/websocket.test.ts +151 -0
  21. package/src/server/account-discovery.ts +49 -0
  22. package/src/server/accounts-routes.ts +74 -0
  23. package/src/server/check-accounts.ts +79 -0
  24. package/src/server/filter-payload.ts +145 -0
  25. package/src/server/graphql/client.ts +103 -0
  26. package/src/server/graphql/mutations.ts +518 -0
  27. package/src/server/history-routes.ts +11 -0
  28. package/src/server/history.ts +53 -0
  29. package/src/server/index.ts +40 -0
  30. package/src/server/otp-manager.ts +63 -0
  31. package/src/server/payload-schemas/amex.schema.ts +2 -0
  32. package/src/server/payload-schemas/cal.schema.ts +27 -0
  33. package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
  34. package/src/server/payload-schemas/discount.schema.ts +26 -0
  35. package/src/server/payload-schemas/isracard.schema.ts +58 -0
  36. package/src/server/payload-schemas/max.schema.ts +27 -0
  37. package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
  38. package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
  39. package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
  40. package/src/server/scrape-runner.ts +165 -0
  41. package/src/server/scrapers/__tests__/amex.test.ts +142 -0
  42. package/src/server/scrapers/__tests__/cal.test.ts +135 -0
  43. package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
  44. package/src/server/scrapers/__tests__/discount.test.ts +160 -0
  45. package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
  46. package/src/server/scrapers/__tests__/max.test.ts +115 -0
  47. package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
  48. package/src/server/scrapers/amex.ts +63 -0
  49. package/src/server/scrapers/cal.ts +56 -0
  50. package/src/server/scrapers/currency-rates.ts +64 -0
  51. package/src/server/scrapers/discount.ts +62 -0
  52. package/src/server/scrapers/isracard.ts +68 -0
  53. package/src/server/scrapers/max.ts +32 -0
  54. package/src/server/scrapers/poalim.ts +103 -0
  55. package/src/server/settings-routes.ts +27 -0
  56. package/src/server/sources-routes.ts +182 -0
  57. package/src/server/validate-payload.ts +74 -0
  58. package/src/server/vault-routes.ts +99 -0
  59. package/src/server/vault-store.ts +42 -0
  60. package/src/server/vault.ts +216 -0
  61. package/src/server/websocket.ts +454 -0
  62. package/src/shared/source-types.ts +10 -0
  63. package/src/shared/types.ts +20 -0
  64. package/src/shared/ws-protocol.ts +177 -0
  65. package/src/test-setup.ts +6 -0
  66. package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
  67. package/src/ui/__tests__/config.test.tsx +99 -0
  68. package/src/ui/__tests__/history.test.tsx +94 -0
  69. package/src/ui/__tests__/run.test.tsx +195 -0
  70. package/src/ui/__tests__/settings-tab.test.tsx +79 -0
  71. package/src/ui/__tests__/sources-tab.test.tsx +139 -0
  72. package/src/ui/__tests__/vault-setup.test.tsx +105 -0
  73. package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
  74. package/src/ui/app.tsx +109 -0
  75. package/src/ui/components/error-boundary.tsx +54 -0
  76. package/src/ui/components/otp-modal.tsx +82 -0
  77. package/src/ui/components/skeleton.tsx +58 -0
  78. package/src/ui/components/task-row.tsx +241 -0
  79. package/src/ui/contexts/vault-context.tsx +77 -0
  80. package/src/ui/lib/api.ts +117 -0
  81. package/src/ui/lib/ws.ts +137 -0
  82. package/src/ui/main.tsx +9 -0
  83. package/src/ui/screens/config/accounts-tab.tsx +185 -0
  84. package/src/ui/screens/config/config.tsx +163 -0
  85. package/src/ui/screens/config/settings-tab.tsx +167 -0
  86. package/src/ui/screens/config/source-forms.tsx +518 -0
  87. package/src/ui/screens/config/source-types.ts +91 -0
  88. package/src/ui/screens/config/sources-tab.tsx +176 -0
  89. package/src/ui/screens/history.tsx +234 -0
  90. package/src/ui/screens/run.tsx +266 -0
  91. package/src/ui/screens/vault-setup.tsx +120 -0
  92. package/src/ui/screens/vault-unlock.tsx +38 -0
  93. package/tsconfig.json +15 -0
  94. package/tsup.config.ts +10 -0
  95. package/vite.config.ts +24 -0
  96. package/vitest.config.ts +7 -0
@@ -0,0 +1,185 @@
1
+ import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react';
2
+ import { AccountRecord } from '../../../server/vault.js';
3
+ import { deleteAccount, fetchAccounts, updateStatus } from '../../lib/api.js';
4
+ import { SOURCE_LABELS } from './source-types.js';
5
+
6
+ type SourceType = 'poalim' | 'discount' | 'isracard' | 'amex' | 'cal' | 'max';
7
+ type AccountStatus = 'accepted' | 'ignored' | 'pending';
8
+
9
+ const STATUS_COLORS: Record<AccountStatus, string> = {
10
+ accepted: '#2a7a2a',
11
+ ignored: '#999',
12
+ pending: '#b85c00',
13
+ };
14
+
15
+ function StatusBadge({ status }: { status: AccountStatus }): ReactElement {
16
+ return (
17
+ <span
18
+ style={{
19
+ padding: '2px 8px',
20
+ borderRadius: 4,
21
+ fontSize: '0.8em',
22
+ color: '#fff',
23
+ background: STATUS_COLORS[status],
24
+ }}
25
+ >
26
+ {status}
27
+ </span>
28
+ );
29
+ }
30
+
31
+ type AccountRowProps = {
32
+ account: AccountRecord;
33
+ onStatusChange(id: string, status: 'accepted' | 'ignored'): void;
34
+ onDelete(id: string): void;
35
+ };
36
+
37
+ function AccountRow({ account, onStatusChange, onDelete }: AccountRowProps): ReactElement {
38
+ const label = account.branchNumber
39
+ ? `${account.accountNumber} / branch ${account.branchNumber}`
40
+ : account.accountNumber;
41
+
42
+ return (
43
+ <li
44
+ style={{
45
+ padding: '8px 0',
46
+ borderBottom: '1px solid #eee',
47
+ }}
48
+ >
49
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
50
+ <span style={{ flex: 1 }}>{label}</span>
51
+ <StatusBadge status={account.status} />
52
+ <select
53
+ aria-label={`Status for ${label}`}
54
+ value={account.status}
55
+ onChange={e => {
56
+ const v = e.target.value as 'accepted' | 'ignored';
57
+ if (v === 'accepted' || v === 'ignored') onStatusChange(account.id, v);
58
+ }}
59
+ >
60
+ <option value="pending">pending</option>
61
+ <option value="accepted">accepted</option>
62
+ <option value="ignored">ignored</option>
63
+ </select>
64
+ <button
65
+ aria-label={`Delete ${label}`}
66
+ onClick={() => onDelete(account.id)}
67
+ style={{
68
+ color: '#c00',
69
+ background: 'none',
70
+ border: 'none',
71
+ cursor: 'pointer',
72
+ padding: '0 4px',
73
+ }}
74
+ >
75
+
76
+ </button>
77
+ </div>
78
+ {account.status === 'pending' && (
79
+ <p style={{ margin: '4px 0 0', fontSize: '0.85em', color: '#b85c00' }}>
80
+ Visit the Accounter client to set up this account.
81
+ </p>
82
+ )}
83
+ </li>
84
+ );
85
+ }
86
+
87
+ export function AccountsTab(): ReactElement {
88
+ const [accounts, setAccounts] = useState<(AccountRecord & { nickname?: string })[]>([]);
89
+ const [error, setError] = useState<string | null>(null);
90
+ const [pendingOnly, setPendingOnly] = useState(false);
91
+
92
+ const load = useCallback(async () => {
93
+ try {
94
+ setAccounts(await fetchAccounts());
95
+ } catch {
96
+ setError('Failed to load accounts');
97
+ }
98
+ }, []);
99
+
100
+ useEffect(() => {
101
+ void load();
102
+ }, [load]);
103
+
104
+ async function handleStatusChange(id: string, status: 'accepted' | 'ignored') {
105
+ try {
106
+ setAccounts(await updateStatus(id, status));
107
+ } catch {
108
+ setError('Failed to update account');
109
+ }
110
+ }
111
+
112
+ async function handleDelete(id: string) {
113
+ try {
114
+ setAccounts(await deleteAccount(id));
115
+ } catch {
116
+ setError('Failed to delete account');
117
+ }
118
+ }
119
+
120
+ const grouped = useMemo(
121
+ () =>
122
+ accounts.reduce<Record<string, (AccountRecord & { nickname?: string })[]>>((acc, a) => {
123
+ const key = `${a.sourceType}:${a.nickname ?? a.sourceId}`;
124
+ acc[key] ||= [];
125
+ acc[key].push(a);
126
+ return acc;
127
+ }, {}),
128
+ [accounts],
129
+ );
130
+
131
+ const pending = useMemo(() => accounts.filter(a => a.status === 'pending'), [accounts]);
132
+
133
+ const sections = useMemo(() => {
134
+ if (pendingOnly) {
135
+ return [{ key: 'pending', label: 'Pending accounts', accounts: pending }];
136
+ }
137
+ return Object.entries(grouped).map(([key, accs]) => {
138
+ const [sourceType, sourceId] = key.split(':') as [SourceType, string];
139
+ return {
140
+ key,
141
+ label: `${SOURCE_LABELS[sourceType]} (${sourceId})`,
142
+ accounts: accs,
143
+ };
144
+ });
145
+ }, [pendingOnly, pending, grouped]);
146
+
147
+ return (
148
+ <div>
149
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
150
+ <h2 style={{ margin: 0 }}>Account Records</h2>
151
+ {pending.length > 0 && (
152
+ <button onClick={() => setPendingOnly(p => !p)} style={{ fontSize: '0.85em' }}>
153
+ {pendingOnly ? 'Show all' : `Classify ${pending.length} pending`}
154
+ </button>
155
+ )}
156
+ </div>
157
+
158
+ {error && (
159
+ <p role="alert" style={{ color: 'red' }}>
160
+ {error}
161
+ </p>
162
+ )}
163
+
164
+ {accounts.length === 0 && (
165
+ <p>No account records discovered yet. Run a scrape to populate this list.</p>
166
+ )}
167
+
168
+ {sections.map(section => (
169
+ <section key={section.key} style={{ marginBottom: 20 }}>
170
+ <h3 style={{ marginBottom: 6 }}>{section.label}</h3>
171
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
172
+ {section.accounts.map(a => (
173
+ <AccountRow
174
+ key={a.id}
175
+ account={a}
176
+ onStatusChange={(id, status) => void handleStatusChange(id, status)}
177
+ onDelete={id => void handleDelete(id)}
178
+ />
179
+ ))}
180
+ </ul>
181
+ </section>
182
+ ))}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,163 @@
1
+ import { useEffect, useState, type ChangeEvent, type ReactElement } from 'react';
2
+ import { loadSettings, saveSettings, testConnection } from '../../lib/api.js';
3
+ import { AccountsTab } from './accounts-tab.js';
4
+ import { SettingsTab } from './settings-tab.js';
5
+ import { SourcesTab } from './sources-tab.js';
6
+
7
+ type TabId = 'sources' | 'accounts' | 'settings';
8
+
9
+ const TABS: { id: TabId; label: string }[] = [
10
+ { id: 'sources', label: 'Credentials' },
11
+ { id: 'accounts', label: 'Accounts' },
12
+ { id: 'settings', label: 'Settings' },
13
+ ];
14
+
15
+ type TestResult = { ok: true; latencyMs: number } | { ok: false; error: string };
16
+
17
+ function ConnectionBar(): ReactElement {
18
+ const [serverUrl, setServerUrl] = useState('');
19
+ const [apiKey, setApiKey] = useState('');
20
+ const [testing, setTesting] = useState(false);
21
+ const [testResult, setTestResult] = useState<TestResult | null>(null);
22
+
23
+ useEffect(() => {
24
+ loadSettings().then(s => {
25
+ setServerUrl(s.serverUrl ?? '');
26
+ setApiKey(s.apiKey ?? '');
27
+ });
28
+ }, []);
29
+
30
+ function handleUrlBlur(e: ChangeEvent<HTMLInputElement>) {
31
+ setTestResult(null);
32
+ void saveSettings({ serverUrl: e.target.value || undefined });
33
+ }
34
+
35
+ function handleKeyBlur(e: ChangeEvent<HTMLInputElement>) {
36
+ setTestResult(null);
37
+ void saveSettings({ apiKey: e.target.value || undefined });
38
+ }
39
+
40
+ async function handleTestConnection() {
41
+ setTesting(true);
42
+ setTestResult(null);
43
+ try {
44
+ const result = await testConnection();
45
+ setTestResult(
46
+ result.ok
47
+ ? { ok: true, latencyMs: result.latencyMs ?? 0 }
48
+ : { ok: false, error: result.error ?? 'Unknown error' },
49
+ );
50
+ } catch (err) {
51
+ setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) });
52
+ } finally {
53
+ setTesting(false);
54
+ }
55
+ }
56
+
57
+ return (
58
+ <div
59
+ style={{
60
+ padding: '12px 0 16px',
61
+ marginBottom: 16,
62
+ borderBottom: '1px solid #ddd',
63
+ }}
64
+ >
65
+ <h3 style={{ margin: '0 0 10px' }}>Server connection</h3>
66
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
67
+ <div>
68
+ <label
69
+ htmlFor="conn-server-url"
70
+ style={{ display: 'block', marginBottom: 4, fontSize: '0.9em' }}
71
+ >
72
+ Server URL
73
+ </label>
74
+ <input
75
+ id="conn-server-url"
76
+ type="text"
77
+ value={serverUrl}
78
+ onChange={e => setServerUrl(e.target.value)}
79
+ onBlur={handleUrlBlur}
80
+ placeholder="https://your-accounter-server"
81
+ style={{ padding: '4px 8px', width: 260 }}
82
+ />
83
+ </div>
84
+ <div>
85
+ <label
86
+ htmlFor="conn-api-key"
87
+ style={{ display: 'block', marginBottom: 4, fontSize: '0.9em' }}
88
+ >
89
+ API Key
90
+ </label>
91
+ <input
92
+ id="conn-api-key"
93
+ type="password"
94
+ value={apiKey}
95
+ onChange={e => setApiKey(e.target.value)}
96
+ onBlur={handleKeyBlur}
97
+ placeholder="your-api-key"
98
+ style={{ padding: '4px 8px', width: 200 }}
99
+ />
100
+ </div>
101
+ <button type="button" onClick={() => void handleTestConnection()} disabled={testing}>
102
+ {testing ? 'Testing…' : 'Test connection'}
103
+ </button>
104
+ </div>
105
+ {testResult && (
106
+ <p
107
+ role="status"
108
+ style={{
109
+ margin: '8px 0 0',
110
+ fontSize: '0.85em',
111
+ color: testResult.ok ? '#15803d' : '#b91c1c',
112
+ }}
113
+ >
114
+ {testResult.ok ? `✓ Connected (${testResult.latencyMs} ms)` : `✕ ${testResult.error}`}
115
+ </p>
116
+ )}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ export function Config(): ReactElement {
122
+ const [active, setActive] = useState<TabId>('sources');
123
+
124
+ return (
125
+ <div>
126
+ <nav
127
+ role="tablist"
128
+ style={{ display: 'flex', gap: 4, borderBottom: '2px solid #ddd', marginBottom: 20 }}
129
+ >
130
+ {TABS.map(t => (
131
+ <button
132
+ key={t.id}
133
+ role="tab"
134
+ aria-selected={active === t.id}
135
+ onClick={() => setActive(t.id)}
136
+ style={{
137
+ padding: '8px 16px',
138
+ border: 'none',
139
+ background: 'none',
140
+ cursor: 'pointer',
141
+ borderBottom: active === t.id ? '2px solid #333' : '2px solid transparent',
142
+ fontWeight: active === t.id ? 'bold' : 'normal',
143
+ marginBottom: -2,
144
+ }}
145
+ >
146
+ {t.label}
147
+ </button>
148
+ ))}
149
+ </nav>
150
+
151
+ <div role="tabpanel">
152
+ {active === 'sources' && (
153
+ <>
154
+ <ConnectionBar />
155
+ <SourcesTab />
156
+ </>
157
+ )}
158
+ {active === 'accounts' && <AccountsTab />}
159
+ {active === 'settings' && <SettingsTab />}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,167 @@
1
+ import { useEffect, useState, type ChangeEvent, type ReactElement } from 'react';
2
+ import type { Settings } from '../../../server/vault.js';
3
+ import { getVaultPath, loadSettings, saveSettings } from '../../lib/api.js';
4
+
5
+ export function SettingsTab(): ReactElement {
6
+ const [settings, setSettings] = useState<Settings | null>(null);
7
+ const [vaultPath, setVaultPath] = useState<string>('');
8
+ const [copied, setCopied] = useState(false);
9
+ const [error, setError] = useState<string | null>(null);
10
+
11
+ useEffect(() => {
12
+ loadSettings()
13
+ .then(s => setSettings(s))
14
+ .catch(() => setError('Failed to load settings'));
15
+ getVaultPath()
16
+ .then(r => setVaultPath(r.path))
17
+ .catch(() => setVaultPath(''));
18
+ }, []);
19
+
20
+ function handleCopyPath() {
21
+ void navigator.clipboard.writeText(vaultPath).then(() => {
22
+ setCopied(true);
23
+ setTimeout(() => setCopied(false), 2000);
24
+ });
25
+ }
26
+
27
+ async function autoSave(patch: Partial<Settings>) {
28
+ try {
29
+ const updated = await saveSettings(patch);
30
+ setSettings(updated);
31
+ } catch {
32
+ setError('Failed to save settings');
33
+ }
34
+ }
35
+
36
+ function handleToggle(key: 'showBrowser' | 'fetchBankOfIsraelRates' | 'concurrentScraping') {
37
+ return (e: ChangeEvent<HTMLInputElement>) => {
38
+ const value = e.target.checked;
39
+ setSettings(s => (s ? { ...s, [key]: value } : s));
40
+ void autoSave({ [key]: value });
41
+ };
42
+ }
43
+
44
+ function handleNumberBlur(e: ChangeEvent<HTMLInputElement>) {
45
+ const parsed = e.target.value ? parseInt(e.target.value, 10) : undefined;
46
+ if (parsed !== undefined && Number.isNaN(parsed)) return;
47
+ // Fall back to current value when field is cleared; server default takes over on next load
48
+ const value = parsed ?? settings?.defaultDateRangeMonths ?? 3;
49
+ setSettings(s => (s ? { ...s, defaultDateRangeMonths: value } : s));
50
+ void autoSave({ defaultDateRangeMonths: parsed });
51
+ }
52
+
53
+ function handleTextBlur(key: 'historyFilePath') {
54
+ return (e: ChangeEvent<HTMLInputElement>) => {
55
+ const value = e.target.value || settings?.[key] || './history.json';
56
+ setSettings(s => (s ? { ...s, [key]: value } : s));
57
+ void autoSave({ [key]: e.target.value || undefined });
58
+ };
59
+ }
60
+
61
+ if (!settings) return <p>{error ?? 'Loading…'}</p>;
62
+
63
+ return (
64
+ <div>
65
+ <h2>Settings</h2>
66
+
67
+ {error && (
68
+ <p role="alert" style={{ color: 'red' }}>
69
+ {error}
70
+ </p>
71
+ )}
72
+
73
+ <fieldset style={{ border: 'none', padding: 0, margin: 0 }}>
74
+ <legend style={{ fontWeight: 'bold', marginBottom: 12 }}>Scraper options</legend>
75
+
76
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
77
+ <input
78
+ id="showBrowser"
79
+ type="checkbox"
80
+ checked={settings.showBrowser}
81
+ onChange={handleToggle('showBrowser')}
82
+ />
83
+ Show browser during scraping
84
+ </label>
85
+
86
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
87
+ <input
88
+ id="fetchBankOfIsraelRates"
89
+ type="checkbox"
90
+ checked={settings.fetchBankOfIsraelRates}
91
+ onChange={handleToggle('fetchBankOfIsraelRates')}
92
+ />
93
+ Fetch Bank of Israel exchange rates
94
+ </label>
95
+
96
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
97
+ <input
98
+ id="concurrentScraping"
99
+ type="checkbox"
100
+ checked={settings.concurrentScraping}
101
+ onChange={handleToggle('concurrentScraping')}
102
+ />
103
+ Scrape sources concurrently
104
+ </label>
105
+
106
+ <div style={{ marginBottom: 10 }}>
107
+ <label htmlFor="defaultDateRangeMonths" style={{ display: 'block', marginBottom: 4 }}>
108
+ Default date range (months)
109
+ </label>
110
+ <input
111
+ id="defaultDateRangeMonths"
112
+ type="number"
113
+ min={1}
114
+ defaultValue={settings.defaultDateRangeMonths ?? ''}
115
+ onBlur={handleNumberBlur}
116
+ style={{ padding: '4px 8px', width: 80 }}
117
+ placeholder="e.g. 6"
118
+ />
119
+ </div>
120
+
121
+ <div style={{ marginBottom: 10 }}>
122
+ <label htmlFor="historyFilePath" style={{ display: 'block', marginBottom: 4 }}>
123
+ History file path
124
+ </label>
125
+ <input
126
+ id="historyFilePath"
127
+ type="text"
128
+ defaultValue={settings.historyFilePath ?? ''}
129
+ onBlur={handleTextBlur('historyFilePath')}
130
+ style={{ padding: '4px 8px', width: '100%', maxWidth: 400 }}
131
+ placeholder="/path/to/history.json"
132
+ />
133
+ </div>
134
+ </fieldset>
135
+
136
+ {vaultPath && (
137
+ <fieldset style={{ border: 'none', padding: 0, margin: '20px 0 0' }}>
138
+ <legend style={{ fontWeight: 'bold', marginBottom: 12 }}>Vault file</legend>
139
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
140
+ <input
141
+ readOnly
142
+ value={vaultPath}
143
+ style={{
144
+ padding: '4px 8px',
145
+ width: '100%',
146
+ maxWidth: 360,
147
+ color: '#555',
148
+ background: '#f9fafb',
149
+ }}
150
+ aria-label="Vault file path"
151
+ />
152
+ <button
153
+ type="button"
154
+ onClick={handleCopyPath}
155
+ style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
156
+ >
157
+ {copied ? '✓ Copied' : 'Copy path'}
158
+ </button>
159
+ </div>
160
+ <p style={{ margin: 0, fontSize: '0.85em', color: '#6b7280' }}>
161
+ Back up this file to preserve your credentials.
162
+ </p>
163
+ </fieldset>
164
+ )}
165
+ </div>
166
+ );
167
+ }