yaver-feedback-react-native 0.4.0 → 0.5.0

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.
@@ -4,9 +4,10 @@ import { YaverDiscovery, DiscoveryResult } from '../Discovery';
4
4
  const mockFetch = jest.fn();
5
5
  global.fetch = mockFetch as any;
6
6
 
7
- // Mock AsyncStorage
7
+ // Mock AsyncStorage. Discovery.ts requires it via `.default`, so the mock
8
+ // must expose its API on a `default` property too.
8
9
  const mockStorage: Record<string, string> = {};
9
- jest.mock('@react-native-async-storage/async-storage', () => ({
10
+ const mockAsyncStorage = {
10
11
  getItem: jest.fn((key: string) => Promise.resolve(mockStorage[key] || null)),
11
12
  setItem: jest.fn((key: string, value: string) => {
12
13
  mockStorage[key] = value;
@@ -16,6 +17,11 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
16
17
  delete mockStorage[key];
17
18
  return Promise.resolve();
18
19
  }),
20
+ };
21
+ jest.mock('@react-native-async-storage/async-storage', () => ({
22
+ __esModule: true,
23
+ default: mockAsyncStorage,
24
+ ...mockAsyncStorage,
19
25
  }));
20
26
 
21
27
  // Mock AbortController
@@ -1,10 +1,13 @@
1
1
  import { YaverFeedback } from '../YaverFeedback';
2
2
 
3
- // Mock react-native DeviceEventEmitter
3
+ // Mock react-native: DeviceEventEmitter for event dispatch + Platform so
4
+ // ShakeDetector.start() can branch on iOS without hitting a real RN runtime.
4
5
  jest.mock('react-native', () => ({
5
6
  DeviceEventEmitter: {
6
7
  emit: jest.fn(),
8
+ addListener: jest.fn(() => ({ remove: jest.fn() })),
7
9
  },
10
+ Platform: { OS: 'ios' },
8
11
  }));
9
12
 
10
13
  // Mock Discovery
package/src/auth.ts ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Authentication + device/agent discovery API used by the Yaver Feedback SDK.
3
+ *
4
+ * This module is a trimmed SDK-local port of mobile/src/lib/auth.ts. It only
5
+ * covers what the embedded login/machine-picker flow needs:
6
+ *
7
+ * - Device-code login (`POST /auth/device-code` + `GET /auth/device-code/poll`)
8
+ * so users can sign in via any OAuth provider (apple/google/github/gitlab/
9
+ * microsoft) on yaver.io without requiring deep-link wiring in the host app.
10
+ * - Email / password sign-up + login (no 2FA flow — for SDK simplicity).
11
+ * - Token validation + refresh.
12
+ * - `/devices/list` → owned + shared (guest) remote dev machines.
13
+ *
14
+ * All calls target the public Yaver Convex site URL by default; callers may
15
+ * override via `init()` config to point at staging.
16
+ *
17
+ * Token persistence uses `@react-native-async-storage/async-storage` (already
18
+ * a peer dep). SecureStore is intentionally avoided to keep the SDK portable
19
+ * to any RN host app.
20
+ */
21
+
22
+ // AsyncStorage is an optional peer dep — degrade gracefully if missing.
23
+ let AsyncStorage: {
24
+ getItem: (key: string) => Promise<string | null>;
25
+ setItem: (key: string, value: string) => Promise<void>;
26
+ removeItem: (key: string) => Promise<void>;
27
+ } | null = null;
28
+ try {
29
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
30
+ } catch {
31
+ // not installed — token persistence disabled, caller must pass authToken
32
+ }
33
+
34
+ const TOKEN_KEY = 'yaver_feedback_auth_token';
35
+ const USER_KEY = 'yaver_feedback_user';
36
+ const DEVICE_KEY = 'yaver_feedback_selected_device';
37
+
38
+ export const DEFAULT_CONVEX_SITE_URL =
39
+ 'https://shocking-echidna-394.eu-west-1.convex.site';
40
+ export const DEFAULT_WEB_BASE_URL = 'https://yaver.io';
41
+
42
+ let convexSiteUrl = DEFAULT_CONVEX_SITE_URL;
43
+ let webBaseUrl = DEFAULT_WEB_BASE_URL;
44
+
45
+ /** Override the Convex site URL + web base (staging vs prod). */
46
+ export function configureAuthEndpoints(opts: {
47
+ convexSiteUrl?: string;
48
+ webBaseUrl?: string;
49
+ }): void {
50
+ if (opts.convexSiteUrl) convexSiteUrl = opts.convexSiteUrl;
51
+ if (opts.webBaseUrl) webBaseUrl = opts.webBaseUrl;
52
+ }
53
+
54
+ export function getConvexSiteUrl(): string {
55
+ return convexSiteUrl;
56
+ }
57
+ export function getWebBaseUrl(): string {
58
+ return webBaseUrl;
59
+ }
60
+
61
+ export type OAuthProvider =
62
+ | 'google'
63
+ | 'microsoft'
64
+ | 'apple'
65
+ | 'github'
66
+ | 'gitlab';
67
+
68
+ export interface User {
69
+ id: string;
70
+ email: string;
71
+ name: string;
72
+ provider?: string;
73
+ avatarUrl?: string;
74
+ }
75
+
76
+ // ─── Token persistence ────────────────────────────────────────────────
77
+
78
+ export async function getToken(): Promise<string | null> {
79
+ if (!AsyncStorage) return null;
80
+ try {
81
+ return await AsyncStorage.getItem(TOKEN_KEY);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export async function saveToken(token: string): Promise<void> {
88
+ if (!AsyncStorage) return;
89
+ try {
90
+ await AsyncStorage.setItem(TOKEN_KEY, token);
91
+ } catch {
92
+ // best effort
93
+ }
94
+ }
95
+
96
+ export async function clearToken(): Promise<void> {
97
+ if (!AsyncStorage) return;
98
+ try {
99
+ await AsyncStorage.removeItem(TOKEN_KEY);
100
+ await AsyncStorage.removeItem(USER_KEY);
101
+ } catch {
102
+ // best effort
103
+ }
104
+ }
105
+
106
+ export async function getUser(): Promise<User | null> {
107
+ if (!AsyncStorage) return null;
108
+ try {
109
+ const raw = await AsyncStorage.getItem(USER_KEY);
110
+ if (!raw) return null;
111
+ return JSON.parse(raw) as User;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ export async function saveUser(user: User): Promise<void> {
118
+ if (!AsyncStorage) return;
119
+ try {
120
+ await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
121
+ } catch {
122
+ // best effort
123
+ }
124
+ }
125
+
126
+ export async function getSelectedDeviceId(): Promise<string | null> {
127
+ if (!AsyncStorage) return null;
128
+ try {
129
+ return await AsyncStorage.getItem(DEVICE_KEY);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ export async function saveSelectedDeviceId(deviceId: string): Promise<void> {
136
+ if (!AsyncStorage) return;
137
+ try {
138
+ await AsyncStorage.setItem(DEVICE_KEY, deviceId);
139
+ } catch {
140
+ // best effort
141
+ }
142
+ }
143
+
144
+ export async function clearSelectedDeviceId(): Promise<void> {
145
+ if (!AsyncStorage) return;
146
+ try {
147
+ await AsyncStorage.removeItem(DEVICE_KEY);
148
+ } catch {
149
+ // best effort
150
+ }
151
+ }
152
+
153
+ // ─── Token validation ──────────────────────────────────────────────────
154
+
155
+ export async function validateToken(token: string): Promise<User | null> {
156
+ try {
157
+ const controller = new AbortController();
158
+ const timeout = setTimeout(() => controller.abort(), 5_000);
159
+ const res = await fetch(`${convexSiteUrl}/auth/validate`, {
160
+ method: 'GET',
161
+ headers: { Authorization: `Bearer ${token}` },
162
+ signal: controller.signal,
163
+ });
164
+ clearTimeout(timeout);
165
+ if (!res.ok) return null;
166
+ const data = await res.json();
167
+ const u = data.user;
168
+ return {
169
+ id: u.userId ?? u.id,
170
+ email: u.email,
171
+ name: u.fullName ?? u.name,
172
+ provider: u.provider,
173
+ avatarUrl: u.avatarUrl,
174
+ };
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ // ─── Device-code flow (for OAuth via web) ─────────────────────────────
181
+
182
+ export interface DeviceCodeStart {
183
+ userCode: string;
184
+ deviceCode: string;
185
+ expiresAt: number;
186
+ verificationUrl: string;
187
+ }
188
+
189
+ /**
190
+ * Start a device-code flow. The user opens `verificationUrl`, signs in with
191
+ * any OAuth provider on yaver.io, and the SDK polls `pollDeviceCode` until
192
+ * a session token is issued.
193
+ */
194
+ export async function startDeviceCode(opts?: {
195
+ machineName?: string;
196
+ platform?: string;
197
+ preferredProvider?: OAuthProvider;
198
+ }): Promise<DeviceCodeStart> {
199
+ const res = await fetch(`${convexSiteUrl}/auth/device-code`, {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({
203
+ machineName: opts?.machineName,
204
+ platform: opts?.platform,
205
+ preferredProvider: opts?.preferredProvider,
206
+ environment: 'feedback-sdk',
207
+ }),
208
+ });
209
+ if (!res.ok) {
210
+ const data = await res.json().catch(() => ({}));
211
+ throw new Error(data.error ?? 'Failed to start device-code');
212
+ }
213
+ const data = await res.json();
214
+ const params = new URLSearchParams({ code: data.userCode });
215
+ if (opts?.preferredProvider) {
216
+ params.set('preferredProvider', opts.preferredProvider);
217
+ }
218
+ return {
219
+ userCode: data.userCode,
220
+ deviceCode: data.deviceCode,
221
+ expiresAt: data.expiresAt,
222
+ verificationUrl: `${webBaseUrl}/auth/device?${params.toString()}`,
223
+ };
224
+ }
225
+
226
+ export type DeviceCodePoll =
227
+ | { status: 'pending' }
228
+ | { status: 'authorized'; token: string }
229
+ | { status: 'expired' };
230
+
231
+ export async function pollDeviceCode(
232
+ deviceCode: string,
233
+ ): Promise<DeviceCodePoll> {
234
+ try {
235
+ const res = await fetch(
236
+ `${convexSiteUrl}/auth/device-code/poll?device_code=${encodeURIComponent(deviceCode)}`,
237
+ );
238
+ if (!res.ok) return { status: 'expired' };
239
+ const data = await res.json();
240
+ if (data.status === 'authorized' && typeof data.token === 'string') {
241
+ return { status: 'authorized', token: data.token };
242
+ }
243
+ if (data.status === 'pending') return { status: 'pending' };
244
+ return { status: 'expired' };
245
+ } catch {
246
+ return { status: 'pending' }; // network blip — let caller keep polling
247
+ }
248
+ }
249
+
250
+ // ─── Email / password (no 2FA) ────────────────────────────────────────
251
+
252
+ export async function signupWithEmail(
253
+ fullName: string,
254
+ email: string,
255
+ password: string,
256
+ ): Promise<{ token: string; userId: string }> {
257
+ const res = await fetch(`${convexSiteUrl}/auth/signup`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ fullName, email, password }),
261
+ });
262
+ if (!res.ok) {
263
+ const data = await res.json().catch(() => ({}));
264
+ throw new Error(data.error ?? 'Signup failed');
265
+ }
266
+ return res.json();
267
+ }
268
+
269
+ export async function loginWithEmail(
270
+ email: string,
271
+ password: string,
272
+ ): Promise<{ token: string; userId: string; requires2fa?: boolean }> {
273
+ const res = await fetch(`${convexSiteUrl}/auth/login`, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ email, password }),
277
+ });
278
+ if (!res.ok) {
279
+ const data = await res.json().catch(() => ({}));
280
+ throw new Error(data.error ?? 'Login failed');
281
+ }
282
+ const data = await res.json();
283
+ if (data?.requires2fa) {
284
+ // SDK login surface does not handle 2FA — direct the user to complete
285
+ // sign-in through the web flow (device-code) which supports it.
286
+ throw new Error(
287
+ '2FA is enabled on this account. Sign in via the device-code flow instead.',
288
+ );
289
+ }
290
+ return { token: data.token, userId: data.userId };
291
+ }
292
+
293
+ // ─── Devices (owned + shared) ─────────────────────────────────────────
294
+
295
+ export interface RemoteDevice {
296
+ deviceId: string;
297
+ name: string;
298
+ platform: string;
299
+ isOnline: boolean;
300
+ needsAuth: boolean;
301
+ runnerDown: boolean;
302
+ lastHeartbeat: number;
303
+ isGuest: boolean;
304
+ hostName?: string;
305
+ hostEmail?: string;
306
+ accessScope: 'owner' | 'shared-scoped' | 'shared-legacy';
307
+ quicHost: string;
308
+ quicPort: number;
309
+ publicKey?: string;
310
+ }
311
+
312
+ export interface DeviceList {
313
+ owned: RemoteDevice[];
314
+ shared: RemoteDevice[];
315
+ }
316
+
317
+ /**
318
+ * Fetch the set of remote dev machines this user can reach. Splits into
319
+ * owned (user is the host) vs shared (host invited them as a guest).
320
+ */
321
+ export async function listReachableDevices(
322
+ token: string,
323
+ ): Promise<DeviceList> {
324
+ try {
325
+ const res = await fetch(`${convexSiteUrl}/devices/list`, {
326
+ headers: { Authorization: `Bearer ${token}` },
327
+ });
328
+ if (!res.ok) return { owned: [], shared: [] };
329
+ const data = await res.json();
330
+ const all = (data.devices ?? []) as RemoteDevice[];
331
+ return {
332
+ owned: all.filter((d) => !d.isGuest),
333
+ shared: all.filter((d) => d.isGuest),
334
+ };
335
+ } catch {
336
+ return { owned: [], shared: [] };
337
+ }
338
+ }
package/src/index.ts CHANGED
@@ -24,14 +24,50 @@
24
24
 
25
25
  export { YaverFeedback } from './YaverFeedback';
26
26
  export { BlackBox } from './BlackBox';
27
+ export { YaverUpdates } from './YaverUpdates';
28
+ export type { YaverUpdatesConfig, PendingUpdate } from './YaverUpdates';
27
29
  export { initExpo } from './expo';
28
30
  export { YaverDiscovery } from './Discovery';
29
31
  export { P2PClient } from './P2PClient';
30
32
  export { YaverConnectionScreen } from './ConnectionScreen';
33
+ export { YaverLoginScreen } from './LoginScreen';
34
+ export type { YaverLoginScreenProps } from './LoginScreen';
35
+ export { YaverMachinePickerScreen } from './MachinePickerScreen';
36
+ export type { YaverMachinePickerProps } from './MachinePickerScreen';
37
+ export { AuthOverlay } from './AuthOverlay';
31
38
  export { ShakeDetector } from './ShakeDetector';
32
39
  export { FloatingButton } from './FloatingButton';
33
40
  export { FeedbackModal } from './FeedbackModal';
34
41
  export { FixReport } from './FixReport';
42
+ export {
43
+ configureAuthEndpoints,
44
+ getConvexSiteUrl,
45
+ getWebBaseUrl,
46
+ getToken,
47
+ saveToken,
48
+ clearToken,
49
+ getUser,
50
+ saveUser,
51
+ getSelectedDeviceId,
52
+ saveSelectedDeviceId,
53
+ clearSelectedDeviceId,
54
+ validateToken,
55
+ startDeviceCode,
56
+ pollDeviceCode,
57
+ signupWithEmail,
58
+ loginWithEmail,
59
+ listReachableDevices,
60
+ DEFAULT_CONVEX_SITE_URL,
61
+ DEFAULT_WEB_BASE_URL,
62
+ } from './auth';
63
+ export type {
64
+ OAuthProvider,
65
+ User,
66
+ DeviceCodeStart,
67
+ DeviceCodePoll,
68
+ RemoteDevice,
69
+ DeviceList,
70
+ } from './auth';
35
71
  export { captureScreenshot, startAudioRecording, stopAudioRecording } from './capture';
36
72
  export { uploadFeedback } from './upload';
37
73
  export type {
package/src/types.ts CHANGED
@@ -1,8 +1,27 @@
1
1
  export interface FeedbackConfig {
2
2
  /** URL of the Yaver agent (e.g. "http://192.168.1.10:18080"). If omitted, auto-discovery is used. */
3
3
  agentUrl?: string;
4
- /** Auth token for the Yaver agent */
5
- authToken: string;
4
+ /**
5
+ * Auth token for the Yaver agent. Optional in 0.5+: if omitted, the SDK
6
+ * will hydrate one from AsyncStorage or show its in-app login screen
7
+ * (device-code / email / OAuth) the first time the user triggers feedback.
8
+ */
9
+ authToken?: string;
10
+ /**
11
+ * When true (default), the SDK will automatically prompt the user to sign
12
+ * in and pick a remote machine the first time they trigger feedback and
13
+ * no `authToken` / `preferredDeviceId` is cached. Set false to opt back
14
+ * into the pre-0.5 behavior where you manage auth yourself and pass
15
+ * `authToken` at init.
16
+ */
17
+ autoLogin?: boolean;
18
+ /**
19
+ * Override the public Yaver endpoints the in-app login screen talks to.
20
+ * Useful when running against staging. Defaults to the production
21
+ * yaver.io / Convex site URLs.
22
+ */
23
+ authConvexSiteUrl?: string;
24
+ authWebBaseUrl?: string;
6
25
  /**
7
26
  * Convex site URL for cloud IP resolution.
8
27
  * When set, the SDK fetches the agent's IP from Convex instead of