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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DemoBackend Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the in-memory demo backend.
|
|
5
|
+
* Only tests IMPLEMENTED operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
+
import { DemoBackend } from '../backend/demo';
|
|
10
|
+
import { OPS } from '../constants';
|
|
11
|
+
import { makeRequest } from '../types';
|
|
12
|
+
|
|
13
|
+
describe('DemoBackend', () => {
|
|
14
|
+
let backend: DemoBackend;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
backend = new DemoBackend();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('connection', () => {
|
|
21
|
+
it('starts disconnected', () => {
|
|
22
|
+
expect(backend.isConnected()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('connects successfully', async () => {
|
|
26
|
+
await backend.connect();
|
|
27
|
+
expect(backend.isConnected()).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('disconnects successfully', async () => {
|
|
31
|
+
await backend.connect();
|
|
32
|
+
backend.disconnect();
|
|
33
|
+
expect(backend.isConnected()).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('runtime.info', () => {
|
|
38
|
+
it('returns runtime info even when disconnected', async () => {
|
|
39
|
+
const req = makeRequest(OPS.RUNTIME_INFO, {});
|
|
40
|
+
const res = await backend.request(req);
|
|
41
|
+
|
|
42
|
+
expect(res.ok).toBe(true);
|
|
43
|
+
expect(res.result).toMatchObject({
|
|
44
|
+
runtime: 'demo',
|
|
45
|
+
engine_present: false,
|
|
46
|
+
mode: 'demo',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('auth.set', () => {
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await backend.connect();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('sets auth context successfully', async () => {
|
|
57
|
+
const req = makeRequest(OPS.AUTH_SET, {
|
|
58
|
+
tenantId: 'tenant-123',
|
|
59
|
+
sub: 'user-456',
|
|
60
|
+
jwt: 'test-jwt',
|
|
61
|
+
});
|
|
62
|
+
const res = await backend.request(req);
|
|
63
|
+
|
|
64
|
+
expect(res.ok).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('fails without required fields', async () => {
|
|
68
|
+
const req = makeRequest(OPS.AUTH_SET, { tenantId: 'test' });
|
|
69
|
+
const res = await backend.request(req);
|
|
70
|
+
|
|
71
|
+
expect(res.ok).toBe(false);
|
|
72
|
+
expect(res.error?.code).toBe('INVALID_PAYLOAD');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('requires connection', async () => {
|
|
76
|
+
backend.disconnect();
|
|
77
|
+
const req = makeRequest(OPS.AUTH_SET, {
|
|
78
|
+
tenantId: 'tenant-123',
|
|
79
|
+
sub: 'user-456',
|
|
80
|
+
jwt: 'test-jwt',
|
|
81
|
+
});
|
|
82
|
+
const res = await backend.request(req);
|
|
83
|
+
|
|
84
|
+
expect(res.ok).toBe(false);
|
|
85
|
+
expect(res.error?.code).toBe('NOT_CONNECTED');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('home.status', () => {
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
await backend.connect();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns BOOTSTRAP_PRE_LOGIN before auth', async () => {
|
|
95
|
+
const req = makeRequest(OPS.HOME_STATUS, {});
|
|
96
|
+
const res = await backend.request(req);
|
|
97
|
+
|
|
98
|
+
expect(res.ok).toBe(true);
|
|
99
|
+
expect((res.result as Record<string, unknown>).state).toBe('BOOTSTRAP_PRE_LOGIN');
|
|
100
|
+
expect((res.result as Record<string, unknown>).grantPresent).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns AUTHENTICATED_NO_HOME_GRANT after auth', async () => {
|
|
104
|
+
// Set auth first
|
|
105
|
+
await backend.request(
|
|
106
|
+
makeRequest(OPS.AUTH_SET, {
|
|
107
|
+
tenantId: 'tenant-123',
|
|
108
|
+
sub: 'user-456',
|
|
109
|
+
jwt: 'test-jwt',
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const req = makeRequest(OPS.HOME_STATUS, {});
|
|
114
|
+
const res = await backend.request(req);
|
|
115
|
+
|
|
116
|
+
expect(res.ok).toBe(true);
|
|
117
|
+
expect((res.result as Record<string, unknown>).state).toBe('AUTHENTICATED_NO_HOME_GRANT');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('home.grant', () => {
|
|
122
|
+
beforeEach(async () => {
|
|
123
|
+
await backend.connect();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('fails without auth', async () => {
|
|
127
|
+
const req = makeRequest(OPS.HOME_GRANT, {});
|
|
128
|
+
const res = await backend.request(req);
|
|
129
|
+
|
|
130
|
+
expect(res.ok).toBe(false);
|
|
131
|
+
expect(res.error?.code).toBe('NOT_AUTHENTICATED');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('succeeds with auth', async () => {
|
|
135
|
+
// Set auth first
|
|
136
|
+
await backend.request(
|
|
137
|
+
makeRequest(OPS.AUTH_SET, {
|
|
138
|
+
tenantId: 'tenant-123',
|
|
139
|
+
sub: 'user-456',
|
|
140
|
+
jwt: 'test-jwt',
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const req = makeRequest(OPS.HOME_GRANT, {});
|
|
145
|
+
const res = await backend.request(req);
|
|
146
|
+
|
|
147
|
+
expect(res.ok).toBe(true);
|
|
148
|
+
expect((res.result as Record<string, unknown>).success).toBe(true);
|
|
149
|
+
expect((res.result as Record<string, unknown>).grant_id).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('changes status to HOME_GRANTED', async () => {
|
|
153
|
+
// Set auth
|
|
154
|
+
await backend.request(
|
|
155
|
+
makeRequest(OPS.AUTH_SET, {
|
|
156
|
+
tenantId: 'tenant-123',
|
|
157
|
+
sub: 'user-456',
|
|
158
|
+
jwt: 'test-jwt',
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Request grant
|
|
163
|
+
await backend.request(makeRequest(OPS.HOME_GRANT, {}));
|
|
164
|
+
|
|
165
|
+
// Check status
|
|
166
|
+
const res = await backend.request(makeRequest(OPS.HOME_STATUS, {}));
|
|
167
|
+
|
|
168
|
+
expect(res.ok).toBe(true);
|
|
169
|
+
expect((res.result as Record<string, unknown>).state).toBe('HOME_GRANTED');
|
|
170
|
+
expect((res.result as Record<string, unknown>).grantPresent).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('unknown operation', () => {
|
|
175
|
+
beforeEach(async () => {
|
|
176
|
+
await backend.connect();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns error for unknown op', async () => {
|
|
180
|
+
const req = makeRequest('unknown.op', {});
|
|
181
|
+
const res = await backend.request(req);
|
|
182
|
+
|
|
183
|
+
expect(res.ok).toBe(false);
|
|
184
|
+
expect(res.error?.code).toBe('INVALID_OP');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Event Store
|
|
3
|
+
* In-memory store for audit events with subscription support.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AuditEvent } from './types';
|
|
7
|
+
|
|
8
|
+
// In-memory event store
|
|
9
|
+
const events: AuditEvent[] = [];
|
|
10
|
+
const listeners: Set<() => void> = new Set();
|
|
11
|
+
|
|
12
|
+
let eventCounter = 0;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a unique event ID.
|
|
16
|
+
*/
|
|
17
|
+
function generateEventId(): string {
|
|
18
|
+
eventCounter += 1;
|
|
19
|
+
return `evt_${Date.now()}_${eventCounter}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Notify all subscribers of a change.
|
|
24
|
+
*/
|
|
25
|
+
function notifyListeners(): void {
|
|
26
|
+
listeners.forEach((listener) => listener());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Add a new audit event to the store.
|
|
31
|
+
*/
|
|
32
|
+
export function addAuditEvent(
|
|
33
|
+
event: Omit<AuditEvent, 'id' | 'timestamp'>
|
|
34
|
+
): void {
|
|
35
|
+
const fullEvent: AuditEvent = {
|
|
36
|
+
...event,
|
|
37
|
+
id: generateEventId(),
|
|
38
|
+
timestamp: new Date(),
|
|
39
|
+
};
|
|
40
|
+
events.unshift(fullEvent); // Add to beginning (newest first)
|
|
41
|
+
notifyListeners();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get all audit events (newest first).
|
|
46
|
+
*/
|
|
47
|
+
export function getAuditEvents(): AuditEvent[] {
|
|
48
|
+
return [...events];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clear all audit events.
|
|
53
|
+
*/
|
|
54
|
+
export function clearAuditEvents(): void {
|
|
55
|
+
events.length = 0;
|
|
56
|
+
notifyListeners();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Subscribe to audit event changes.
|
|
61
|
+
* Returns an unsubscribe function.
|
|
62
|
+
*/
|
|
63
|
+
export function subscribeToAuditEvents(listener: () => void): () => void {
|
|
64
|
+
listeners.add(listener);
|
|
65
|
+
return () => {
|
|
66
|
+
listeners.delete(listener);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Event Types
|
|
3
|
+
* Type definitions for the audit logging system.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Represents an audit event in the system.
|
|
8
|
+
*/
|
|
9
|
+
export interface AuditEvent {
|
|
10
|
+
/** Unique identifier for the event */
|
|
11
|
+
id: string;
|
|
12
|
+
/** When the event occurred */
|
|
13
|
+
timestamp: Date;
|
|
14
|
+
/** Event type (e.g., 'db.put', 'auth.login') */
|
|
15
|
+
type: string;
|
|
16
|
+
/** Human-readable description */
|
|
17
|
+
description: string;
|
|
18
|
+
/** Optional explanation for non-technical users */
|
|
19
|
+
explanation?: string;
|
|
20
|
+
/** Optional technical details for debugging */
|
|
21
|
+
technical?: Record<string, unknown>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth API Client
|
|
3
|
+
*
|
|
4
|
+
* Handles authentication requests to the EKKA API.
|
|
5
|
+
* All HTTP proxied through Rust via engine_request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { OPS } from '../constants';
|
|
9
|
+
import { _internal, makeRequest } from '../internal';
|
|
10
|
+
import type {
|
|
11
|
+
LoginResponse,
|
|
12
|
+
RefreshResponse,
|
|
13
|
+
AuthTokens,
|
|
14
|
+
UserInfo,
|
|
15
|
+
} from './types';
|
|
16
|
+
import {
|
|
17
|
+
setTokens,
|
|
18
|
+
clearTokens,
|
|
19
|
+
getRefreshToken,
|
|
20
|
+
getAccessToken,
|
|
21
|
+
setUser,
|
|
22
|
+
getUser,
|
|
23
|
+
clearUser,
|
|
24
|
+
} from './storage';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* API request error with status code and error details.
|
|
28
|
+
*/
|
|
29
|
+
export class ApiRequestError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly code: string;
|
|
32
|
+
|
|
33
|
+
constructor(message: string, status: number, code: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'ApiRequestError';
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// PUBLIC API
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Login with email/username and password.
|
|
47
|
+
* Stores tokens and user info on success.
|
|
48
|
+
*
|
|
49
|
+
* @throws ApiRequestError on authentication failure
|
|
50
|
+
*/
|
|
51
|
+
export async function login(identifier: string, password: string): Promise<LoginResponse> {
|
|
52
|
+
const req = makeRequest(OPS.AUTH_LOGIN, {
|
|
53
|
+
identifier,
|
|
54
|
+
password,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const engineResponse = await _internal.request(req);
|
|
58
|
+
|
|
59
|
+
if (!engineResponse.ok) {
|
|
60
|
+
const error = engineResponse.error;
|
|
61
|
+
throw new ApiRequestError(
|
|
62
|
+
error?.message || 'Login failed',
|
|
63
|
+
error?.status || 401,
|
|
64
|
+
error?.code || 'AUTH_ERROR'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const response = engineResponse.result as LoginResponse;
|
|
69
|
+
|
|
70
|
+
// Store tokens and user
|
|
71
|
+
setTokens({
|
|
72
|
+
access_token: response.access_token,
|
|
73
|
+
refresh_token: response.refresh_token,
|
|
74
|
+
});
|
|
75
|
+
setUser(response.user);
|
|
76
|
+
|
|
77
|
+
// Notify listeners
|
|
78
|
+
notifyAuthChange(true);
|
|
79
|
+
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Refresh the access token using the stored refresh token.
|
|
85
|
+
* Stores new tokens on success.
|
|
86
|
+
*
|
|
87
|
+
* @throws Error if no refresh token available
|
|
88
|
+
* @throws ApiRequestError if refresh fails
|
|
89
|
+
*/
|
|
90
|
+
export async function refresh(): Promise<AuthTokens> {
|
|
91
|
+
const refreshToken = getRefreshToken();
|
|
92
|
+
|
|
93
|
+
if (!refreshToken) {
|
|
94
|
+
throw new Error('No refresh token available');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const req = makeRequest(OPS.AUTH_REFRESH, {
|
|
98
|
+
refresh_token: refreshToken,
|
|
99
|
+
jwt: getAccessToken(), // Include current access token if available
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const engineResponse = await _internal.request(req);
|
|
104
|
+
|
|
105
|
+
if (!engineResponse.ok) {
|
|
106
|
+
const error = engineResponse.error;
|
|
107
|
+
throw new ApiRequestError(
|
|
108
|
+
error?.message || 'Token refresh failed',
|
|
109
|
+
error?.status || 401,
|
|
110
|
+
error?.code || 'AUTH_ERROR'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const response = engineResponse.result as RefreshResponse;
|
|
115
|
+
|
|
116
|
+
// Store new tokens
|
|
117
|
+
setTokens({
|
|
118
|
+
access_token: response.access_token,
|
|
119
|
+
refresh_token: response.refresh_token,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Notify listeners
|
|
123
|
+
notifyAuthChange(true);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
access_token: response.access_token,
|
|
127
|
+
refresh_token: response.refresh_token,
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// Clear tokens on refresh failure (session expired)
|
|
131
|
+
clearTokens();
|
|
132
|
+
clearUser();
|
|
133
|
+
notifyAuthChange(false);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Logout the current user.
|
|
140
|
+
* Clears all stored tokens and user info.
|
|
141
|
+
*/
|
|
142
|
+
export async function logout(): Promise<void> {
|
|
143
|
+
const refreshToken = getRefreshToken();
|
|
144
|
+
|
|
145
|
+
// Attempt to notify server (best effort)
|
|
146
|
+
if (refreshToken) {
|
|
147
|
+
try {
|
|
148
|
+
const req = makeRequest(OPS.AUTH_LOGOUT, {
|
|
149
|
+
refresh_token: refreshToken,
|
|
150
|
+
});
|
|
151
|
+
await _internal.request(req);
|
|
152
|
+
} catch {
|
|
153
|
+
// Ignore server errors during logout
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Always clear local state
|
|
158
|
+
clearTokens();
|
|
159
|
+
clearUser();
|
|
160
|
+
notifyAuthChange(false);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if user is currently authenticated.
|
|
165
|
+
*/
|
|
166
|
+
export function isAuthenticated(): boolean {
|
|
167
|
+
return getAccessToken() !== null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get current user info.
|
|
172
|
+
*/
|
|
173
|
+
export function getCurrentUser(): UserInfo | null {
|
|
174
|
+
return getUser();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// =============================================================================
|
|
178
|
+
// AUTH STATE CHANGE LISTENERS
|
|
179
|
+
// =============================================================================
|
|
180
|
+
|
|
181
|
+
type AuthChangeListener = (isAuthenticated: boolean) => void;
|
|
182
|
+
const authChangeListeners: Set<AuthChangeListener> = new Set();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Subscribe to authentication state changes.
|
|
186
|
+
* Returns an unsubscribe function.
|
|
187
|
+
*/
|
|
188
|
+
export function onAuthChange(listener: AuthChangeListener): () => void {
|
|
189
|
+
authChangeListeners.add(listener);
|
|
190
|
+
return () => {
|
|
191
|
+
authChangeListeners.delete(listener);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Notify all listeners of auth state change.
|
|
197
|
+
*/
|
|
198
|
+
function notifyAuthChange(isAuthenticated: boolean): void {
|
|
199
|
+
authChangeListeners.forEach((listener) => {
|
|
200
|
+
try {
|
|
201
|
+
listener(isAuthenticated);
|
|
202
|
+
} catch {
|
|
203
|
+
// Ignore listener errors
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// =============================================================================
|
|
209
|
+
// RE-EXPORTS
|
|
210
|
+
// =============================================================================
|
|
211
|
+
|
|
212
|
+
export type { UserInfo, AuthTokens } from './types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Module Exports
|
|
3
|
+
*
|
|
4
|
+
* Public API for authentication.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type {
|
|
9
|
+
LoginRequest,
|
|
10
|
+
LoginResponse,
|
|
11
|
+
RefreshRequest,
|
|
12
|
+
RefreshResponse,
|
|
13
|
+
AuthTokens,
|
|
14
|
+
AuthState,
|
|
15
|
+
UserInfo,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
// Client functions
|
|
19
|
+
export {
|
|
20
|
+
login,
|
|
21
|
+
logout,
|
|
22
|
+
refresh,
|
|
23
|
+
isAuthenticated,
|
|
24
|
+
getCurrentUser,
|
|
25
|
+
onAuthChange,
|
|
26
|
+
ApiRequestError,
|
|
27
|
+
} from './client';
|
|
28
|
+
|
|
29
|
+
// Storage helpers
|
|
30
|
+
export { getAccessToken, hasStoredRefreshToken } from './storage';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token & User Storage
|
|
3
|
+
*
|
|
4
|
+
* Memory storage for access token (secure - never persisted)
|
|
5
|
+
* localStorage for refresh token (persistent across sessions)
|
|
6
|
+
* localStorage for user info (persistent across sessions)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AuthTokens, UserInfo } from './types';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// STORAGE KEYS
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const REFRESH_TOKEN_KEY = 'ekka.auth.refresh_token';
|
|
16
|
+
const USER_KEY = 'ekka.auth.user';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// IN-MEMORY TOKEN STORAGE
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
// Access token stored in memory only (never persisted to disk)
|
|
23
|
+
let accessToken: string | null = null;
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// TOKEN FUNCTIONS
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Store authentication tokens.
|
|
31
|
+
* Access token in memory, refresh token in localStorage.
|
|
32
|
+
*/
|
|
33
|
+
export function setTokens(tokens: AuthTokens): void {
|
|
34
|
+
accessToken = tokens.access_token;
|
|
35
|
+
|
|
36
|
+
if (typeof localStorage !== 'undefined') {
|
|
37
|
+
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the current access token from memory.
|
|
43
|
+
*/
|
|
44
|
+
export function getAccessToken(): string | null {
|
|
45
|
+
return accessToken;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the refresh token from localStorage.
|
|
50
|
+
*/
|
|
51
|
+
export function getRefreshToken(): string | null {
|
|
52
|
+
if (typeof localStorage === 'undefined') {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Clear all tokens.
|
|
60
|
+
*/
|
|
61
|
+
export function clearTokens(): void {
|
|
62
|
+
accessToken = null;
|
|
63
|
+
|
|
64
|
+
if (typeof localStorage !== 'undefined') {
|
|
65
|
+
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a refresh token exists in localStorage.
|
|
71
|
+
*/
|
|
72
|
+
export function hasStoredRefreshToken(): boolean {
|
|
73
|
+
return getRefreshToken() !== null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// USER FUNCTIONS
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Store user info in localStorage.
|
|
82
|
+
*/
|
|
83
|
+
export function setUser(user: UserInfo): void {
|
|
84
|
+
if (typeof localStorage !== 'undefined') {
|
|
85
|
+
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get user info from localStorage.
|
|
91
|
+
*/
|
|
92
|
+
export function getUser(): UserInfo | null {
|
|
93
|
+
if (typeof localStorage === 'undefined') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const stored = localStorage.getItem(USER_KEY);
|
|
97
|
+
if (!stored) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(stored) as UserInfo;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clear user info.
|
|
109
|
+
*/
|
|
110
|
+
export function clearUser(): void {
|
|
111
|
+
if (typeof localStorage !== 'undefined') {
|
|
112
|
+
localStorage.removeItem(USER_KEY);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for authentication system.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// REQUEST TYPES
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
export interface LoginRequest {
|
|
12
|
+
identifier: string; // Email or username
|
|
13
|
+
password: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RefreshRequest {
|
|
17
|
+
refresh_token: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LogoutRequest {
|
|
21
|
+
refresh_token: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// RESPONSE TYPES
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export interface UserInfo {
|
|
29
|
+
id: string;
|
|
30
|
+
email: string;
|
|
31
|
+
name: string | null;
|
|
32
|
+
avatar_url: string | null;
|
|
33
|
+
role: string;
|
|
34
|
+
company?: {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LoginResponse {
|
|
41
|
+
user: UserInfo;
|
|
42
|
+
access_token: string;
|
|
43
|
+
refresh_token: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RefreshResponse {
|
|
47
|
+
access_token: string;
|
|
48
|
+
refresh_token: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LogoutResponse {
|
|
52
|
+
message: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// INTERNAL TYPES
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
export interface AuthTokens {
|
|
60
|
+
access_token: string;
|
|
61
|
+
refresh_token: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AuthState {
|
|
65
|
+
isAuthenticated: boolean;
|
|
66
|
+
user: UserInfo | null;
|
|
67
|
+
}
|