@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,139 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import { userEvent } from '@testing-library/user-event';
5
+ import React from 'react';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { SourcesTab } from '../screens/config/sources-tab.js';
8
+ import type { SourceConfig } from '../screens/config/source-types.js';
9
+
10
+ function mockFetch(sources: SourceConfig[]) {
11
+ const fetchMock = vi.fn(async (url: string, options?: RequestInit) => {
12
+ const method = options?.method ?? 'GET';
13
+
14
+ if (method === 'GET') {
15
+ return { ok: true, json: async () => sources } as Response;
16
+ }
17
+
18
+ if (method === 'POST') {
19
+ const body = JSON.parse(options!.body as string) as Partial<SourceConfig>;
20
+ const added = { ...body, id: 'new-id-1' } as SourceConfig;
21
+ sources = [...sources, added];
22
+ return { ok: true, json: async () => sources } as Response;
23
+ }
24
+
25
+ if (method === 'PUT') {
26
+ const id = (url as string).split('/').pop()!;
27
+ const patch = JSON.parse(options!.body as string) as Partial<SourceConfig>;
28
+ sources = sources.map(s => (s.id === id ? { ...s, ...patch } : s));
29
+ return { ok: true, json: async () => sources } as Response;
30
+ }
31
+
32
+ if (method === 'DELETE') {
33
+ const id = (url as string).split('/').pop()!;
34
+ sources = sources.filter(s => s.id !== id);
35
+ return { ok: true, json: async () => sources } as Response;
36
+ }
37
+
38
+ return { ok: false, json: async () => ({}) } as Response;
39
+ });
40
+
41
+ vi.stubGlobal('fetch', fetchMock);
42
+ return fetchMock;
43
+ }
44
+
45
+ beforeEach(() => {
46
+ mockFetch([]);
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.unstubAllGlobals();
51
+ });
52
+
53
+ describe('SourcesTab', () => {
54
+ it('renders empty state message', async () => {
55
+ render(<SourcesTab />);
56
+ await waitFor(() => {
57
+ expect(screen.getByText(/no sources configured/i)).toBeTruthy();
58
+ });
59
+ });
60
+
61
+ it('renders a list of existing sources', async () => {
62
+ const sources: SourceConfig[] = [
63
+ { id: 's1', type: 'poalim', userCode: 'u1', password: 'p1', nickname: 'My Poalim' },
64
+ ];
65
+ vi.unstubAllGlobals();
66
+ mockFetch(sources);
67
+
68
+ render(<SourcesTab />);
69
+ await waitFor(() => {
70
+ expect(screen.getByText(/My Poalim/)).toBeTruthy();
71
+ });
72
+ });
73
+
74
+ it('opens add form when Add Source is clicked', async () => {
75
+ render(<SourcesTab />);
76
+ await waitFor(() => screen.getByText(/no sources/i));
77
+
78
+ await userEvent.click(screen.getByRole('button', { name: /add source/i }));
79
+ expect(screen.getByRole('button', { name: /save/i })).toBeTruthy();
80
+ });
81
+
82
+ it('adds a poalim source via form submission', async () => {
83
+ render(<SourcesTab />);
84
+ await waitFor(() => screen.getByText(/no sources/i));
85
+
86
+ await userEvent.click(screen.getByRole('button', { name: /add source/i }));
87
+
88
+ await userEvent.type(screen.getByLabelText(/user code/i), 'user123');
89
+ await userEvent.type(screen.getByLabelText(/^password/i), 'pass456');
90
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
91
+
92
+ await waitFor(() => {
93
+ expect(screen.queryByRole('button', { name: /^save$/i })).toBeNull();
94
+ });
95
+ });
96
+
97
+ it('opens edit form with existing values', async () => {
98
+ const sources: SourceConfig[] = [
99
+ { id: 's1', type: 'max', username: 'maxuser', password: 'pw', nickname: 'Max Account' },
100
+ ];
101
+ vi.unstubAllGlobals();
102
+ mockFetch(sources);
103
+
104
+ render(<SourcesTab />);
105
+ await waitFor(() => screen.getByText(/Max Account/));
106
+
107
+ await userEvent.click(screen.getByRole('button', { name: /edit/i }));
108
+ expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('maxuser');
109
+ });
110
+
111
+ it('deletes a source when Delete is clicked', async () => {
112
+ const sources: SourceConfig[] = [
113
+ { id: 's1', type: 'max', username: 'maxuser', password: 'pw' },
114
+ ];
115
+ vi.unstubAllGlobals();
116
+ mockFetch(sources);
117
+
118
+ render(<SourcesTab />);
119
+ await waitFor(() => screen.getByRole('button', { name: /^delete$/i }));
120
+
121
+ await userEvent.click(screen.getByRole('button', { name: /^delete$/i }));
122
+ await waitFor(() => screen.getByRole('button', { name: /confirm delete/i }));
123
+ await userEvent.click(screen.getByRole('button', { name: /confirm delete/i }));
124
+
125
+ await waitFor(() => {
126
+ expect(screen.getByText(/no sources/i)).toBeTruthy();
127
+ });
128
+ });
129
+
130
+ it('closes form on Cancel', async () => {
131
+ render(<SourcesTab />);
132
+ await waitFor(() => screen.getByText(/no sources/i));
133
+
134
+ await userEvent.click(screen.getByRole('button', { name: /add source/i }));
135
+ await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
136
+
137
+ expect(screen.queryByRole('button', { name: /^save$/i })).toBeNull();
138
+ });
139
+ });
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import { userEvent } from '@testing-library/user-event';
5
+ import React from 'react';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import { VaultContext, type VaultStatus } from '../contexts/vault-context.js';
8
+ import { VaultSetup } from '../screens/vault-setup.js';
9
+
10
+ function makeCtx(create: () => Promise<void> = vi.fn().mockResolvedValue(undefined)) {
11
+ return {
12
+ status: 'no-file' as VaultStatus,
13
+ error: null as string | null,
14
+ unlock: vi.fn(),
15
+ create,
16
+ };
17
+ }
18
+
19
+ function renderSetup(ctx = makeCtx()) {
20
+ return render(
21
+ <VaultContext.Provider value={ctx}>
22
+ <VaultSetup />
23
+ </VaultContext.Provider>,
24
+ );
25
+ }
26
+
27
+ describe('VaultSetup', () => {
28
+ it('renders step 1 on mount', () => {
29
+ renderSetup();
30
+ expect(screen.getByText(/step 1/i)).toBeTruthy();
31
+ expect(screen.getByLabelText(/master password/i)).toBeTruthy();
32
+ });
33
+
34
+ it('shows error when passwords do not match', async () => {
35
+ renderSetup();
36
+ await userEvent.type(screen.getByLabelText(/master password/i), 'password1!');
37
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'different!');
38
+ await userEvent.click(screen.getByRole('button', { name: /next/i }));
39
+
40
+ await waitFor(() => {
41
+ expect(screen.getByRole('alert').textContent).toContain('do not match');
42
+ });
43
+ });
44
+
45
+ it('shows error when password is too short', async () => {
46
+ renderSetup();
47
+ await userEvent.type(screen.getByLabelText(/master password/i), 'short');
48
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'short');
49
+ await userEvent.click(screen.getByRole('button', { name: /next/i }));
50
+
51
+ await waitFor(() => {
52
+ expect(screen.getByRole('alert').textContent).toContain('8 characters');
53
+ });
54
+ });
55
+
56
+ it('advances to step 2 after valid password', async () => {
57
+ renderSetup();
58
+ await userEvent.type(screen.getByLabelText(/master password/i), 'securePwd1!');
59
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'securePwd1!');
60
+ await userEvent.click(screen.getByRole('button', { name: /next/i }));
61
+
62
+ await waitFor(() => {
63
+ expect(screen.getByText(/step 2/i)).toBeTruthy();
64
+ });
65
+ });
66
+
67
+ it('completes the full wizard and calls create', async () => {
68
+ const create = vi.fn().mockResolvedValue(undefined);
69
+ renderSetup(makeCtx(create));
70
+
71
+ // Step 1
72
+ await userEvent.type(screen.getByLabelText(/master password/i), 'securePwd1!');
73
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'securePwd1!');
74
+ await userEvent.click(screen.getByRole('button', { name: /next/i }));
75
+
76
+ // Step 2
77
+ await waitFor(() => screen.getByLabelText(/server url/i));
78
+ await userEvent.type(screen.getByLabelText(/server url/i), 'http://localhost:4000');
79
+ await userEvent.type(screen.getByLabelText(/api key/i), 'my-api-key');
80
+ await userEvent.click(screen.getByRole('button', { name: /next/i }));
81
+
82
+ // Step 3
83
+ await waitFor(() => screen.getByText(/step 3/i));
84
+ expect(screen.getByText(/http:\/\/localhost:4000/)).toBeTruthy();
85
+ await userEvent.click(screen.getByRole('button', { name: /create vault/i }));
86
+
87
+ await waitFor(() => {
88
+ expect(create).toHaveBeenCalledWith('securePwd1!', 'http://localhost:4000', 'my-api-key');
89
+ });
90
+ });
91
+
92
+ it('can navigate back from step 2 to step 1', async () => {
93
+ renderSetup();
94
+ await userEvent.type(screen.getByLabelText(/master password/i), 'securePwd1!');
95
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'securePwd1!');
96
+ await userEvent.click(screen.getByRole('button', { name: /next/i }));
97
+
98
+ await waitFor(() => screen.getByText(/step 2/i));
99
+ await userEvent.click(screen.getByRole('button', { name: /back/i }));
100
+
101
+ await waitFor(() => {
102
+ expect(screen.getByText(/step 1/i)).toBeTruthy();
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,78 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import { userEvent } from '@testing-library/user-event';
5
+ import React, { useState } from 'react';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import { VaultContext, type VaultStatus } from '../contexts/vault-context.js';
8
+ import { VaultUnlock } from '../screens/vault-unlock.js';
9
+
10
+ function makeCtx(
11
+ overrides: Partial<{
12
+ status: VaultStatus;
13
+ error: string | null;
14
+ unlock: (pw: string) => Promise<void>;
15
+ }> = {},
16
+ ) {
17
+ return {
18
+ status: 'locked' as VaultStatus,
19
+ error: null as string | null,
20
+ unlock: vi.fn().mockResolvedValue(undefined),
21
+ create: vi.fn(),
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function renderUnlock(ctx = makeCtx()) {
27
+ return render(
28
+ <VaultContext.Provider value={ctx}>
29
+ <VaultUnlock />
30
+ </VaultContext.Provider>,
31
+ );
32
+ }
33
+
34
+ describe('VaultUnlock', () => {
35
+ it('renders the password input and submit button', () => {
36
+ renderUnlock();
37
+ expect(screen.getByLabelText(/master password/i)).toBeTruthy();
38
+ expect(screen.getByRole('button', { name: /unlock/i })).toBeTruthy();
39
+ });
40
+
41
+ it('calls unlock with the entered password on submit', async () => {
42
+ const unlock = vi.fn().mockResolvedValue(undefined);
43
+ renderUnlock(makeCtx({ unlock }));
44
+
45
+ await userEvent.type(screen.getByLabelText(/master password/i), 'secret123');
46
+ await userEvent.click(screen.getByRole('button', { name: /unlock/i }));
47
+
48
+ expect(unlock).toHaveBeenCalledWith('secret123');
49
+ });
50
+
51
+ it('shows an error message when unlock sets vault.error', async () => {
52
+ // Stateful wrapper simulates the real context updating error after a failed unlock.
53
+ function UnlockWrapper() {
54
+ const [error, setError] = useState<string | null>(null);
55
+ const unlock = vi.fn().mockImplementation(async () => {
56
+ setError('Wrong password. Please try again.');
57
+ });
58
+ return (
59
+ <VaultContext.Provider value={{ status: 'locked', error, unlock, create: vi.fn() }}>
60
+ <VaultUnlock />
61
+ </VaultContext.Provider>
62
+ );
63
+ }
64
+ render(<UnlockWrapper />);
65
+
66
+ await userEvent.type(screen.getByLabelText(/master password/i), 'badpass');
67
+ await userEvent.click(screen.getByRole('button', { name: /unlock/i }));
68
+
69
+ await waitFor(() => {
70
+ expect(screen.getByRole('alert').textContent).toContain('Wrong password');
71
+ });
72
+ });
73
+
74
+ it('does not show an error on initial render', () => {
75
+ renderUnlock();
76
+ expect(screen.queryByRole('alert')).toBeNull();
77
+ });
78
+ });
package/src/ui/app.tsx ADDED
@@ -0,0 +1,109 @@
1
+ import { useState, type ReactElement } from 'react';
2
+ import { ErrorBoundary } from './components/error-boundary.js';
3
+ import { useVault, VaultContext, VaultProvider } from './contexts/vault-context.js';
4
+ import { useRunSocket } from './lib/ws.js';
5
+ import { Config } from './screens/config/config.js';
6
+ import { History } from './screens/history.js';
7
+ import { Run } from './screens/run.js';
8
+ import { VaultSetup } from './screens/vault-setup.js';
9
+ import { VaultUnlock } from './screens/vault-unlock.js';
10
+
11
+ export { VaultContext };
12
+
13
+ type AppTab = 'run' | 'history' | 'config';
14
+
15
+ const TABS: { id: AppTab; label: string }[] = [
16
+ { id: 'run', label: 'Run' },
17
+ { id: 'history', label: 'History' },
18
+ { id: 'config', label: 'Config' },
19
+ ];
20
+
21
+ function hidden(visible: boolean): React.CSSProperties {
22
+ return visible ? {} : { display: 'none' };
23
+ }
24
+
25
+ function AppContent(): ReactElement {
26
+ const { status } = useVault();
27
+
28
+ if (status === 'loading') return <div>Loading…</div>;
29
+ if (status === 'no-file')
30
+ return (
31
+ <ErrorBoundary label="VaultSetup">
32
+ <VaultSetup />
33
+ </ErrorBoundary>
34
+ );
35
+ if (status === 'locked')
36
+ return (
37
+ <ErrorBoundary label="VaultUnlock">
38
+ <VaultUnlock />
39
+ </ErrorBoundary>
40
+ );
41
+
42
+ return (
43
+ <ErrorBoundary label="App">
44
+ <AppUnlocked />
45
+ </ErrorBoundary>
46
+ );
47
+ }
48
+
49
+ function AppUnlocked(): ReactElement {
50
+ const [tab, setTab] = useState<AppTab>('run');
51
+ const socket = useRunSocket();
52
+
53
+ return (
54
+ <main style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
55
+ <nav
56
+ role="tablist"
57
+ style={{ display: 'flex', gap: 4, borderBottom: '2px solid #ddd', marginBottom: 20 }}
58
+ >
59
+ {TABS.map(({ id, label }) => (
60
+ <button
61
+ key={id}
62
+ role="tab"
63
+ aria-selected={tab === id}
64
+ onClick={() => setTab(id)}
65
+ style={{
66
+ padding: '8px 16px',
67
+ border: 'none',
68
+ background: 'none',
69
+ cursor: 'pointer',
70
+ borderBottom: tab === id ? '2px solid #333' : '2px solid transparent',
71
+ fontWeight: tab === id ? 'bold' : 'normal',
72
+ marginBottom: -2,
73
+ }}
74
+ >
75
+ {label}
76
+ {id === 'run' && socket.runStatus === 'running' && (
77
+ <span style={{ marginLeft: 6, color: '#2563eb', fontSize: '0.75em' }}>●</span>
78
+ )}
79
+ </button>
80
+ ))}
81
+ </nav>
82
+
83
+ {/* Always mounted — visibility toggled via CSS so WS state survives tab switches */}
84
+ <div style={hidden(tab === 'run')}>
85
+ <ErrorBoundary label="Run">
86
+ <Run {...socket} onNavigateAccounts={() => setTab('config')} isVisible={tab === 'run'} />
87
+ </ErrorBoundary>
88
+ </div>
89
+ {tab === 'history' && (
90
+ <ErrorBoundary label="History">
91
+ <History />
92
+ </ErrorBoundary>
93
+ )}
94
+ {tab === 'config' && (
95
+ <ErrorBoundary label="Config">
96
+ <Config />
97
+ </ErrorBoundary>
98
+ )}
99
+ </main>
100
+ );
101
+ }
102
+
103
+ export function App(): ReactElement {
104
+ return (
105
+ <VaultProvider>
106
+ <AppContent />
107
+ </VaultProvider>
108
+ );
109
+ }
@@ -0,0 +1,54 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
+
3
+ type Props = { children: ReactNode; label?: string };
4
+ type State = { error: Error | null };
5
+
6
+ export class ErrorBoundary extends Component<Props, State> {
7
+ override state: State = { error: null };
8
+
9
+ static override getDerivedStateFromError(error: Error): State {
10
+ return { error };
11
+ }
12
+
13
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
14
+ // Log to Fastify server log via console.error so it appears in server output
15
+ console.error(`[ErrorBoundary:${this.props.label ?? 'unknown'}]`, error, info.componentStack);
16
+ }
17
+
18
+ override render(): ReactNode {
19
+ if (this.state.error) {
20
+ return (
21
+ <div
22
+ role="alert"
23
+ style={{
24
+ padding: 24,
25
+ margin: 16,
26
+ border: '1px solid #fca5a5',
27
+ borderRadius: 8,
28
+ background: '#fef2f2',
29
+ }}
30
+ >
31
+ <h3 style={{ margin: '0 0 8px', color: '#b91c1c' }}>Something went wrong</h3>
32
+ <p
33
+ style={{
34
+ margin: '0 0 16px',
35
+ color: '#7f1d1d',
36
+ fontSize: '0.9em',
37
+ fontFamily: 'monospace',
38
+ }}
39
+ >
40
+ {this.state.error.message}
41
+ </p>
42
+ <button
43
+ type="button"
44
+ onClick={() => window.location.reload()}
45
+ style={{ padding: '6px 16px', cursor: 'pointer' }}
46
+ >
47
+ Reload
48
+ </button>
49
+ </div>
50
+ );
51
+ }
52
+ return this.props.children;
53
+ }
54
+ }
@@ -0,0 +1,82 @@
1
+ import { useState, type ReactElement } from 'react';
2
+ import type { ClientMessage } from '../../shared/ws-protocol.js';
3
+
4
+ type Props = {
5
+ sourceId: string;
6
+ onSubmit: (msg: ClientMessage) => void;
7
+ };
8
+
9
+ export function OtpModal({ sourceId, onSubmit }: Props): ReactElement {
10
+ const [otp, setOtp] = useState('');
11
+
12
+ function handleSubmit() {
13
+ if (!otp.trim()) return;
14
+ onSubmit({ type: 'otp-submit', sourceId, otp: otp.trim() });
15
+ setOtp('');
16
+ }
17
+
18
+ return (
19
+ <div
20
+ role="dialog"
21
+ aria-modal="true"
22
+ aria-label="OTP required"
23
+ style={{
24
+ position: 'fixed',
25
+ inset: 0,
26
+ background: 'rgba(0,0,0,0.45)',
27
+ display: 'flex',
28
+ alignItems: 'center',
29
+ justifyContent: 'center',
30
+ zIndex: 1000,
31
+ }}
32
+ >
33
+ <div
34
+ style={{
35
+ background: '#fff',
36
+ borderRadius: 8,
37
+ padding: 28,
38
+ minWidth: 320,
39
+ boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
40
+ }}
41
+ >
42
+ <h2 style={{ margin: '0 0 8px', fontSize: '1.1em' }}>One-Time Password Required</h2>
43
+ <p style={{ margin: '0 0 16px', color: '#555', fontSize: '0.9em' }}>
44
+ Source: <strong>{sourceId}</strong>
45
+ </p>
46
+ <label htmlFor="otp-input" style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
47
+ OTP Code
48
+ </label>
49
+ <input
50
+ id="otp-input"
51
+ type="text"
52
+ value={otp}
53
+ onChange={e => setOtp(e.target.value)}
54
+ onKeyDown={e => {
55
+ if (e.key === 'Enter') handleSubmit();
56
+ }}
57
+ autoFocus
58
+ placeholder="Enter OTP…"
59
+ style={{ width: '100%', padding: '6px 10px', fontSize: '1em', boxSizing: 'border-box' }}
60
+ />
61
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
62
+ <button
63
+ type="button"
64
+ onClick={handleSubmit}
65
+ disabled={!otp.trim()}
66
+ style={{
67
+ padding: '6px 20px',
68
+ background: '#2563eb',
69
+ color: '#fff',
70
+ border: 'none',
71
+ borderRadius: 5,
72
+ cursor: otp.trim() ? 'pointer' : 'default',
73
+ opacity: otp.trim() ? 1 : 0.5,
74
+ }}
75
+ >
76
+ Submit OTP
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,58 @@
1
+ import type { CSSProperties, ReactElement } from 'react';
2
+
3
+ const pulse: CSSProperties = {
4
+ background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
5
+ backgroundSize: '200% 100%',
6
+ animation: 'skeleton-pulse 1.4s ease infinite',
7
+ borderRadius: 4,
8
+ };
9
+
10
+ // Inject keyframes once via a style tag approach — no CSS file needed
11
+ let injected = false;
12
+ function ensureKeyframes() {
13
+ if (injected || typeof document === 'undefined') return;
14
+ injected = true;
15
+ const style = document.createElement('style');
16
+ style.textContent = `@keyframes skeleton-pulse { 0%{background-position:200% 0} 100%{background-position:-200% 0} }`;
17
+ document.head.appendChild(style);
18
+ }
19
+
20
+ export function SkeletonRow({
21
+ height = 18,
22
+ width = '100%',
23
+ }: {
24
+ height?: number;
25
+ width?: number | string;
26
+ }): ReactElement {
27
+ ensureKeyframes();
28
+ return <div style={{ ...pulse, height, width, marginBottom: 8 }} />;
29
+ }
30
+
31
+ export function SkeletonTable({
32
+ rows = 4,
33
+ cols = 5,
34
+ }: {
35
+ rows?: number;
36
+ cols?: number;
37
+ }): ReactElement {
38
+ ensureKeyframes();
39
+ return (
40
+ <table
41
+ style={{ width: '100%', borderCollapse: 'collapse' }}
42
+ aria-busy="true"
43
+ aria-label="Loading…"
44
+ >
45
+ <tbody>
46
+ {Array.from({ length: rows }, (_, r) => (
47
+ <tr key={r} style={{ borderBottom: '1px solid #f0f0f0' }}>
48
+ {Array.from({ length: cols }, (_, c) => (
49
+ <td key={c} style={{ padding: '10px 8px' }}>
50
+ <div style={{ ...pulse, height: 14, width: c === 0 ? '70%' : '50%' }} />
51
+ </td>
52
+ ))}
53
+ </tr>
54
+ ))}
55
+ </tbody>
56
+ </table>
57
+ );
58
+ }