yaver-feedback-react-native 0.8.2 → 0.8.3
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/dist/AuthOverlay.js +32 -2
- package/dist/Discovery.js +2 -0
- package/dist/FeedbackModal.js +22 -1
- package/dist/GuestOnboardingScreen.d.ts +8 -0
- package/dist/GuestOnboardingScreen.js +282 -0
- package/dist/LoginScreen.d.ts +5 -1
- package/dist/LoginScreen.js +5 -2
- package/dist/MachinePickerScreen.js +1 -1
- package/dist/P2PClient.d.ts +13 -0
- package/dist/P2PClient.js +22 -0
- package/dist/YaverFeedback.d.ts +2 -0
- package/dist/YaverFeedback.js +46 -4
- package/dist/auth.d.ts +52 -0
- package/dist/auth.js +75 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -1
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/AuthOverlay.tsx +46 -2
- package/src/Discovery.ts +2 -0
- package/src/FeedbackModal.tsx +22 -1
- package/src/GuestOnboardingScreen.tsx +307 -0
- package/src/LoginScreen.tsx +21 -2
- package/src/MachinePickerScreen.tsx +3 -1
- package/src/P2PClient.ts +33 -0
- package/src/YaverFeedback.ts +50 -4
- package/src/auth.ts +137 -0
- package/src/index.ts +11 -0
- package/src/types.ts +7 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Pressable,
|
|
5
|
+
SafeAreaView,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
TextInput,
|
|
10
|
+
View,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import {
|
|
13
|
+
acceptGuestByCode,
|
|
14
|
+
acceptGuestInvitation,
|
|
15
|
+
fetchGuestHosts,
|
|
16
|
+
findInviteByCode,
|
|
17
|
+
type GuestHostsResponse,
|
|
18
|
+
type InvitationPreview,
|
|
19
|
+
} from './auth';
|
|
20
|
+
|
|
21
|
+
export interface YaverGuestOnboardingScreenProps {
|
|
22
|
+
token: string;
|
|
23
|
+
initialInviteCode?: string;
|
|
24
|
+
onContinue: () => void;
|
|
25
|
+
onCancel?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const YaverGuestOnboardingScreen: React.FC<YaverGuestOnboardingScreenProps> = ({
|
|
29
|
+
token,
|
|
30
|
+
initialInviteCode,
|
|
31
|
+
onContinue,
|
|
32
|
+
onCancel,
|
|
33
|
+
}) => {
|
|
34
|
+
const [code, setCode] = useState((initialInviteCode ?? '').toUpperCase());
|
|
35
|
+
const [preview, setPreview] = useState<InvitationPreview | null>(null);
|
|
36
|
+
const [hosts, setHosts] = useState<GuestHostsResponse>({ pending: [], active: [] });
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const cleanedCode = useMemo(() => code.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6), [code]);
|
|
41
|
+
|
|
42
|
+
const loadHosts = useCallback(async () => {
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
const result = await fetchGuestHosts(token);
|
|
47
|
+
setHosts(result);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}
|
|
53
|
+
}, [token]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
void loadHosts();
|
|
57
|
+
}, [loadHosts]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
let cancelled = false;
|
|
61
|
+
if (cleanedCode.length !== 6) {
|
|
62
|
+
setPreview(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
(async () => {
|
|
66
|
+
try {
|
|
67
|
+
const result = await findInviteByCode(token, cleanedCode);
|
|
68
|
+
if (!cancelled) {
|
|
69
|
+
setPreview(result);
|
|
70
|
+
if (!result) {
|
|
71
|
+
setError('Invite code not found or expired.');
|
|
72
|
+
} else {
|
|
73
|
+
setError(null);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
setPreview(null);
|
|
79
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
};
|
|
86
|
+
}, [cleanedCode, token]);
|
|
87
|
+
|
|
88
|
+
const handleAcceptCode = useCallback(async () => {
|
|
89
|
+
if (cleanedCode.length !== 6) {
|
|
90
|
+
setError('Enter the 6-character invite code from the host.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setBusy(true);
|
|
94
|
+
setError(null);
|
|
95
|
+
try {
|
|
96
|
+
await acceptGuestByCode(token, cleanedCode, preview?.proposedDeviceIds);
|
|
97
|
+
await loadHosts();
|
|
98
|
+
onContinue();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
101
|
+
} finally {
|
|
102
|
+
setBusy(false);
|
|
103
|
+
}
|
|
104
|
+
}, [cleanedCode, loadHosts, onContinue, preview?.proposedDeviceIds, token]);
|
|
105
|
+
|
|
106
|
+
const handleAcceptPending = useCallback(async (hostUserId: string) => {
|
|
107
|
+
setBusy(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
try {
|
|
110
|
+
await acceptGuestInvitation(token, hostUserId);
|
|
111
|
+
await loadHosts();
|
|
112
|
+
onContinue();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(false);
|
|
117
|
+
}
|
|
118
|
+
}, [loadHosts, onContinue, token]);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<SafeAreaView style={styles.container}>
|
|
122
|
+
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
|
123
|
+
<View style={styles.header}>
|
|
124
|
+
<View>
|
|
125
|
+
<Text style={styles.title}>Guest Access</Text>
|
|
126
|
+
<Text style={styles.subtitle}>
|
|
127
|
+
Join a host's repo-scoped Feedback SDK access without leaving the app.
|
|
128
|
+
</Text>
|
|
129
|
+
</View>
|
|
130
|
+
{onCancel && (
|
|
131
|
+
<Pressable onPress={onCancel} style={styles.skipBtn}>
|
|
132
|
+
<Text style={styles.skipText}>Skip</Text>
|
|
133
|
+
</Pressable>
|
|
134
|
+
)}
|
|
135
|
+
</View>
|
|
136
|
+
|
|
137
|
+
<View style={styles.card}>
|
|
138
|
+
<Text style={styles.cardTitle}>Have an invite code?</Text>
|
|
139
|
+
<Text style={styles.cardText}>
|
|
140
|
+
Enter the 6-character code from the host. After redemption you can pick the shared machine directly here.
|
|
141
|
+
</Text>
|
|
142
|
+
<TextInput
|
|
143
|
+
style={styles.input}
|
|
144
|
+
value={cleanedCode}
|
|
145
|
+
onChangeText={setCode}
|
|
146
|
+
placeholder="ABC123"
|
|
147
|
+
placeholderTextColor="#667085"
|
|
148
|
+
autoCapitalize="characters"
|
|
149
|
+
autoCorrect={false}
|
|
150
|
+
maxLength={6}
|
|
151
|
+
/>
|
|
152
|
+
{preview && (
|
|
153
|
+
<View style={styles.previewBox}>
|
|
154
|
+
<Text style={styles.previewTitle}>{preview.hostName}</Text>
|
|
155
|
+
<Text style={styles.previewMeta}>{preview.hostEmail}</Text>
|
|
156
|
+
<Text style={styles.previewMeta}>
|
|
157
|
+
{preview.hostDevices.length > 0
|
|
158
|
+
? `${preview.hostDevices.length} host machine${preview.hostDevices.length === 1 ? '' : 's'} shared`
|
|
159
|
+
: 'Host machine list will appear after acceptance'}
|
|
160
|
+
</Text>
|
|
161
|
+
</View>
|
|
162
|
+
)}
|
|
163
|
+
<Pressable
|
|
164
|
+
onPress={() => void handleAcceptCode()}
|
|
165
|
+
disabled={busy || cleanedCode.length !== 6}
|
|
166
|
+
style={({ pressed }) => [
|
|
167
|
+
styles.primaryBtn,
|
|
168
|
+
(pressed || busy || cleanedCode.length !== 6) && styles.primaryBtnPressed,
|
|
169
|
+
]}
|
|
170
|
+
>
|
|
171
|
+
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.primaryBtnText}>Redeem Code</Text>}
|
|
172
|
+
</Pressable>
|
|
173
|
+
</View>
|
|
174
|
+
|
|
175
|
+
<View style={styles.card}>
|
|
176
|
+
<Text style={styles.cardTitle}>Pending host invites</Text>
|
|
177
|
+
<Text style={styles.cardText}>
|
|
178
|
+
If a host invited your email directly, it should appear here after you sign up.
|
|
179
|
+
</Text>
|
|
180
|
+
{loading ? (
|
|
181
|
+
<ActivityIndicator color="#98a2b3" style={{ marginTop: 12 }} />
|
|
182
|
+
) : hosts.pending.length > 0 ? (
|
|
183
|
+
hosts.pending.map((invite) => (
|
|
184
|
+
<View key={`${invite.hostUserId}:${invite.createdAt}`} style={styles.inviteRow}>
|
|
185
|
+
<View style={{ flex: 1 }}>
|
|
186
|
+
<Text style={styles.inviteName}>{invite.hostName}</Text>
|
|
187
|
+
<Text style={styles.inviteMeta}>{invite.hostEmail}</Text>
|
|
188
|
+
</View>
|
|
189
|
+
<Pressable
|
|
190
|
+
onPress={() => void handleAcceptPending(invite.hostUserId)}
|
|
191
|
+
disabled={busy}
|
|
192
|
+
style={({ pressed }) => [
|
|
193
|
+
styles.secondaryBtn,
|
|
194
|
+
(pressed || busy) && styles.secondaryBtnPressed,
|
|
195
|
+
]}
|
|
196
|
+
>
|
|
197
|
+
<Text style={styles.secondaryBtnText}>Accept</Text>
|
|
198
|
+
</Pressable>
|
|
199
|
+
</View>
|
|
200
|
+
))
|
|
201
|
+
) : (
|
|
202
|
+
<Text style={styles.emptyText}>No pending host invites on this account yet.</Text>
|
|
203
|
+
)}
|
|
204
|
+
</View>
|
|
205
|
+
|
|
206
|
+
<View style={styles.cardMuted}>
|
|
207
|
+
<Text style={styles.cardTitle}>No machine yet?</Text>
|
|
208
|
+
<Text style={styles.cardText}>
|
|
209
|
+
That is fine. You can still sign up here and redeem the host's guest access. Shared machines will appear once the host has one running. Later you can install Yaver on your own machine too.
|
|
210
|
+
</Text>
|
|
211
|
+
</View>
|
|
212
|
+
|
|
213
|
+
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
214
|
+
|
|
215
|
+
<Pressable
|
|
216
|
+
onPress={onContinue}
|
|
217
|
+
style={({ pressed }) => [styles.linkBtn, pressed && { opacity: 0.7 }]}
|
|
218
|
+
>
|
|
219
|
+
<Text style={styles.linkBtnText}>Continue to machine picker</Text>
|
|
220
|
+
</Pressable>
|
|
221
|
+
</ScrollView>
|
|
222
|
+
</SafeAreaView>
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const styles = StyleSheet.create({
|
|
227
|
+
container: { flex: 1, backgroundColor: '#0f172a' },
|
|
228
|
+
content: { padding: 20, gap: 16 },
|
|
229
|
+
header: {
|
|
230
|
+
flexDirection: 'row',
|
|
231
|
+
justifyContent: 'space-between',
|
|
232
|
+
alignItems: 'flex-start',
|
|
233
|
+
marginBottom: 8,
|
|
234
|
+
},
|
|
235
|
+
title: { color: '#f8fafc', fontSize: 24, fontWeight: '700' },
|
|
236
|
+
subtitle: { color: '#94a3b8', fontSize: 14, marginTop: 6, maxWidth: 280 },
|
|
237
|
+
skipBtn: { paddingVertical: 6, paddingHorizontal: 10 },
|
|
238
|
+
skipText: { color: '#cbd5e1', fontSize: 13, fontWeight: '600' },
|
|
239
|
+
card: {
|
|
240
|
+
backgroundColor: 'rgba(15,23,42,0.82)',
|
|
241
|
+
borderWidth: 1,
|
|
242
|
+
borderColor: 'rgba(148,163,184,0.18)',
|
|
243
|
+
borderRadius: 16,
|
|
244
|
+
padding: 16,
|
|
245
|
+
},
|
|
246
|
+
cardMuted: {
|
|
247
|
+
backgroundColor: 'rgba(30,41,59,0.9)',
|
|
248
|
+
borderRadius: 16,
|
|
249
|
+
padding: 16,
|
|
250
|
+
},
|
|
251
|
+
cardTitle: { color: '#e2e8f0', fontSize: 16, fontWeight: '700' },
|
|
252
|
+
cardText: { color: '#94a3b8', fontSize: 13, lineHeight: 20, marginTop: 8 },
|
|
253
|
+
input: {
|
|
254
|
+
marginTop: 14,
|
|
255
|
+
borderWidth: 1,
|
|
256
|
+
borderColor: 'rgba(148,163,184,0.28)',
|
|
257
|
+
borderRadius: 12,
|
|
258
|
+
paddingHorizontal: 14,
|
|
259
|
+
paddingVertical: 12,
|
|
260
|
+
backgroundColor: 'rgba(2,6,23,0.88)',
|
|
261
|
+
color: '#f8fafc',
|
|
262
|
+
fontSize: 18,
|
|
263
|
+
letterSpacing: 3,
|
|
264
|
+
textAlign: 'center',
|
|
265
|
+
},
|
|
266
|
+
previewBox: {
|
|
267
|
+
marginTop: 12,
|
|
268
|
+
padding: 12,
|
|
269
|
+
borderRadius: 12,
|
|
270
|
+
backgroundColor: 'rgba(30,41,59,0.75)',
|
|
271
|
+
},
|
|
272
|
+
previewTitle: { color: '#f8fafc', fontSize: 15, fontWeight: '600' },
|
|
273
|
+
previewMeta: { color: '#94a3b8', fontSize: 12, marginTop: 4 },
|
|
274
|
+
primaryBtn: {
|
|
275
|
+
marginTop: 14,
|
|
276
|
+
backgroundColor: '#2563eb',
|
|
277
|
+
borderRadius: 12,
|
|
278
|
+
minHeight: 46,
|
|
279
|
+
alignItems: 'center',
|
|
280
|
+
justifyContent: 'center',
|
|
281
|
+
},
|
|
282
|
+
primaryBtnPressed: { opacity: 0.6 },
|
|
283
|
+
primaryBtnText: { color: '#fff', fontSize: 15, fontWeight: '700' },
|
|
284
|
+
inviteRow: {
|
|
285
|
+
flexDirection: 'row',
|
|
286
|
+
alignItems: 'center',
|
|
287
|
+
gap: 12,
|
|
288
|
+
marginTop: 12,
|
|
289
|
+
paddingTop: 12,
|
|
290
|
+
borderTopWidth: 1,
|
|
291
|
+
borderTopColor: 'rgba(148,163,184,0.12)',
|
|
292
|
+
},
|
|
293
|
+
inviteName: { color: '#f8fafc', fontSize: 14, fontWeight: '600' },
|
|
294
|
+
inviteMeta: { color: '#94a3b8', fontSize: 12, marginTop: 4 },
|
|
295
|
+
secondaryBtn: {
|
|
296
|
+
paddingHorizontal: 12,
|
|
297
|
+
paddingVertical: 10,
|
|
298
|
+
borderRadius: 10,
|
|
299
|
+
backgroundColor: 'rgba(37,99,235,0.16)',
|
|
300
|
+
},
|
|
301
|
+
secondaryBtnPressed: { opacity: 0.6 },
|
|
302
|
+
secondaryBtnText: { color: '#bfdbfe', fontSize: 13, fontWeight: '700' },
|
|
303
|
+
emptyText: { color: '#64748b', fontSize: 13, marginTop: 12 },
|
|
304
|
+
error: { color: '#f87171', fontSize: 13, marginTop: 4 },
|
|
305
|
+
linkBtn: { alignItems: 'center', paddingVertical: 8 },
|
|
306
|
+
linkBtnText: { color: '#cbd5e1', fontSize: 14, fontWeight: '600' },
|
|
307
|
+
});
|
package/src/LoginScreen.tsx
CHANGED
|
@@ -113,9 +113,11 @@ const iconStyles = StyleSheet.create({
|
|
|
113
113
|
|
|
114
114
|
export interface YaverLoginScreenProps {
|
|
115
115
|
/** Invoked once a session token is issued and the user is loaded. */
|
|
116
|
-
onLoggedIn: (token: string) => void;
|
|
116
|
+
onLoggedIn: (token: string, opts?: { inviteCode?: string }) => void;
|
|
117
117
|
/** Optional cancel button shown in header. */
|
|
118
118
|
onCancel?: () => void;
|
|
119
|
+
/** Optional prefilled guest invite code from config / deep link. */
|
|
120
|
+
initialInviteCode?: string;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/**
|
|
@@ -126,6 +128,7 @@ export interface YaverLoginScreenProps {
|
|
|
126
128
|
export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
|
|
127
129
|
onLoggedIn,
|
|
128
130
|
onCancel,
|
|
131
|
+
initialInviteCode,
|
|
129
132
|
}) => {
|
|
130
133
|
const [busyProvider, setBusyProvider] = useState<OAuthProvider | 'apple' | null>(null);
|
|
131
134
|
const [showEmailForm, setShowEmailForm] = useState(false);
|
|
@@ -134,6 +137,7 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
|
|
|
134
137
|
const [email, setEmail] = useState('');
|
|
135
138
|
const [password, setPassword] = useState('');
|
|
136
139
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
140
|
+
const [inviteCode, setInviteCode] = useState((initialInviteCode ?? '').toUpperCase());
|
|
137
141
|
const [emailBusy, setEmailBusy] = useState(false);
|
|
138
142
|
const [emailError, setEmailError] = useState('');
|
|
139
143
|
|
|
@@ -141,7 +145,8 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
|
|
|
141
145
|
const user = await validateToken(token);
|
|
142
146
|
await saveToken(token);
|
|
143
147
|
if (user) await saveUser(user);
|
|
144
|
-
|
|
148
|
+
const cleanedInviteCode = inviteCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6);
|
|
149
|
+
onLoggedIn(token, cleanedInviteCode ? { inviteCode: cleanedInviteCode } : undefined);
|
|
145
150
|
};
|
|
146
151
|
|
|
147
152
|
const handleApple = async () => {
|
|
@@ -321,6 +326,20 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
|
|
|
321
326
|
secureTextEntry
|
|
322
327
|
/>
|
|
323
328
|
)}
|
|
329
|
+
{isSignUp && (
|
|
330
|
+
<TextInput
|
|
331
|
+
style={styles.input}
|
|
332
|
+
placeholder="Invite Code (optional)"
|
|
333
|
+
placeholderTextColor="#666"
|
|
334
|
+
value={inviteCode}
|
|
335
|
+
onChangeText={(value) =>
|
|
336
|
+
setInviteCode(value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6))
|
|
337
|
+
}
|
|
338
|
+
autoCapitalize="characters"
|
|
339
|
+
autoCorrect={false}
|
|
340
|
+
maxLength={6}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
324
343
|
|
|
325
344
|
{emailError ? (
|
|
326
345
|
<Text style={styles.errorText}>{emailError}</Text>
|
|
@@ -53,7 +53,9 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
|
|
|
53
53
|
const result = await listReachableDevices(token);
|
|
54
54
|
setList(result);
|
|
55
55
|
if (result.owned.length === 0 && result.shared.length === 0) {
|
|
56
|
-
setError(
|
|
56
|
+
setError(
|
|
57
|
+
'No machines found yet. If you do not have your own computer, redeem a host invite code first. Otherwise run `yaver auth` + `yaver serve` on your machine.',
|
|
58
|
+
);
|
|
57
59
|
}
|
|
58
60
|
} catch (err) {
|
|
59
61
|
setError(err instanceof Error ? err.message : String(err));
|
package/src/P2PClient.ts
CHANGED
|
@@ -449,6 +449,39 @@ export class P2PClient {
|
|
|
449
449
|
return response.json();
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
+
async getVibingEligibility(
|
|
453
|
+
opts?: { projectName?: string; bundleId?: string; projectPath?: string },
|
|
454
|
+
): Promise<{
|
|
455
|
+
canVibe: boolean;
|
|
456
|
+
reason?: string;
|
|
457
|
+
guidance?: string;
|
|
458
|
+
projectName?: string;
|
|
459
|
+
projectPath?: string;
|
|
460
|
+
provider?: string;
|
|
461
|
+
repoFullName?: string;
|
|
462
|
+
}> {
|
|
463
|
+
const identity = resolveAppIdentity(opts);
|
|
464
|
+
const params = new URLSearchParams();
|
|
465
|
+
if (identity.projectName ?? opts?.projectName) {
|
|
466
|
+
params.set('projectName', identity.projectName ?? opts?.projectName ?? '');
|
|
467
|
+
}
|
|
468
|
+
if (identity.bundleId ?? opts?.bundleId) {
|
|
469
|
+
params.set('bundleId', identity.bundleId ?? opts?.bundleId ?? '');
|
|
470
|
+
}
|
|
471
|
+
if (identity.projectPath ?? opts?.projectPath) {
|
|
472
|
+
params.set('projectPath', identity.projectPath ?? opts?.projectPath ?? '');
|
|
473
|
+
}
|
|
474
|
+
const response = await fetch(`${this.baseUrl}/vibing/eligibility?${params.toString()}`, {
|
|
475
|
+
method: 'GET',
|
|
476
|
+
headers: { Authorization: `Bearer ${this.authToken}` },
|
|
477
|
+
});
|
|
478
|
+
if (!response.ok) {
|
|
479
|
+
const text = await response.text().catch(() => '');
|
|
480
|
+
throw new Error(`[P2PClient] Vibing eligibility failed (${response.status}): ${text}`);
|
|
481
|
+
}
|
|
482
|
+
return response.json();
|
|
483
|
+
}
|
|
484
|
+
|
|
452
485
|
/**
|
|
453
486
|
* After uploading a feedback bundle with `uploadFeedback`, call this
|
|
454
487
|
* with the returned report id to create a fix task on the agent. The
|
package/src/YaverFeedback.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getToken,
|
|
11
11
|
getSelectedDeviceId,
|
|
12
12
|
listReachableDevices,
|
|
13
|
+
mintGuestSdkToken,
|
|
13
14
|
clearToken,
|
|
14
15
|
clearSelectedDeviceId,
|
|
15
16
|
DEFAULT_CONVEX_SITE_URL,
|
|
@@ -49,6 +50,7 @@ let config: FeedbackConfig | null = null;
|
|
|
49
50
|
let enabled = false;
|
|
50
51
|
let p2pClient: P2PClient | null = null;
|
|
51
52
|
let shakeDetector: ShakeDetector | null = null;
|
|
53
|
+
let p2pAuthToken: string | null = null;
|
|
52
54
|
|
|
53
55
|
/** Ring buffer of captured errors. */
|
|
54
56
|
let errorBuffer: CapturedError[] = [];
|
|
@@ -77,6 +79,44 @@ const flagCache: Map<string, { value: unknown; at: number }> = new Map();
|
|
|
77
79
|
* Call `YaverFeedback.init()` once at app startup.
|
|
78
80
|
*/
|
|
79
81
|
export class YaverFeedback {
|
|
82
|
+
private static async resolveP2PAuthToken(): Promise<string | null> {
|
|
83
|
+
if (!config?.authToken) return null;
|
|
84
|
+
if (!config.preferredDeviceId) return config.authToken;
|
|
85
|
+
const devices = await listReachableDevices(config.authToken);
|
|
86
|
+
const all = [...devices.owned, ...devices.shared];
|
|
87
|
+
const selected = all.find((device) => device.deviceId === config?.preferredDeviceId);
|
|
88
|
+
if (!selected || !selected.isGuest || selected.accessScope !== 'shared-scoped') {
|
|
89
|
+
return config.authToken;
|
|
90
|
+
}
|
|
91
|
+
if (!selected.hostUserId) {
|
|
92
|
+
return config.authToken;
|
|
93
|
+
}
|
|
94
|
+
const delegated = await mintGuestSdkToken(
|
|
95
|
+
config.authToken,
|
|
96
|
+
selected.hostUserId,
|
|
97
|
+
selected.deviceId,
|
|
98
|
+
);
|
|
99
|
+
return delegated.token;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static async rebuildP2PClient(agentUrl?: string): Promise<void> {
|
|
103
|
+
if (!config) return;
|
|
104
|
+
const effectiveUrl = agentUrl ?? config.agentUrl;
|
|
105
|
+
if (!effectiveUrl) {
|
|
106
|
+
p2pClient = null;
|
|
107
|
+
p2pAuthToken = null;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const token = await YaverFeedback.resolveP2PAuthToken();
|
|
111
|
+
if (!token) {
|
|
112
|
+
p2pClient = null;
|
|
113
|
+
p2pAuthToken = null;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
p2pAuthToken = token;
|
|
117
|
+
p2pClient = new P2PClient(effectiveUrl, token);
|
|
118
|
+
}
|
|
119
|
+
|
|
80
120
|
/**
|
|
81
121
|
* Initialize the feedback SDK with the given configuration.
|
|
82
122
|
* Typically called in your app's root component or entry file.
|
|
@@ -130,7 +170,11 @@ export class YaverFeedback {
|
|
|
130
170
|
|
|
131
171
|
// Create P2P client if we have a URL
|
|
132
172
|
if (config.agentUrl) {
|
|
173
|
+
p2pAuthToken = config.authToken ?? null;
|
|
133
174
|
p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '');
|
|
175
|
+
if (config.authToken) {
|
|
176
|
+
void YaverFeedback.rebuildP2PClient(config.agentUrl);
|
|
177
|
+
}
|
|
134
178
|
} else {
|
|
135
179
|
p2pClient = null;
|
|
136
180
|
// Auto-discover agent in the background when convexUrl or LAN is available
|
|
@@ -251,7 +295,7 @@ export class YaverFeedback {
|
|
|
251
295
|
});
|
|
252
296
|
if (result && config) {
|
|
253
297
|
config.agentUrl = result.url;
|
|
254
|
-
|
|
298
|
+
await YaverFeedback.rebuildP2PClient(result.url);
|
|
255
299
|
}
|
|
256
300
|
} catch {
|
|
257
301
|
// Discovery failed — FloatingButton will show disconnected, user can retry
|
|
@@ -278,7 +322,7 @@ export class YaverFeedback {
|
|
|
278
322
|
});
|
|
279
323
|
if (!result) return false;
|
|
280
324
|
config.agentUrl = result.url;
|
|
281
|
-
|
|
325
|
+
await YaverFeedback.rebuildP2PClient(result.url);
|
|
282
326
|
return true;
|
|
283
327
|
} catch {
|
|
284
328
|
return false;
|
|
@@ -322,7 +366,7 @@ export class YaverFeedback {
|
|
|
322
366
|
if (!config) return;
|
|
323
367
|
config.authToken = token;
|
|
324
368
|
if (config.agentUrl) {
|
|
325
|
-
|
|
369
|
+
await YaverFeedback.rebuildP2PClient(config.agentUrl);
|
|
326
370
|
} else {
|
|
327
371
|
await YaverFeedback.discoverAgent();
|
|
328
372
|
}
|
|
@@ -363,6 +407,7 @@ export class YaverFeedback {
|
|
|
363
407
|
config.preferredDeviceId = deviceId;
|
|
364
408
|
config.agentUrl = undefined;
|
|
365
409
|
p2pClient = null;
|
|
410
|
+
p2pAuthToken = null;
|
|
366
411
|
await YaverFeedback.discoverAgent();
|
|
367
412
|
}
|
|
368
413
|
|
|
@@ -388,6 +433,7 @@ export class YaverFeedback {
|
|
|
388
433
|
config.agentUrl = undefined;
|
|
389
434
|
}
|
|
390
435
|
p2pClient = null;
|
|
436
|
+
p2pAuthToken = null;
|
|
391
437
|
}
|
|
392
438
|
|
|
393
439
|
/**
|
|
@@ -427,7 +473,7 @@ export class YaverFeedback {
|
|
|
427
473
|
});
|
|
428
474
|
if (result) {
|
|
429
475
|
config.agentUrl = result.url;
|
|
430
|
-
|
|
476
|
+
await YaverFeedback.rebuildP2PClient(result.url);
|
|
431
477
|
} else if (config.autoLogin !== false && !config.preferredDeviceId) {
|
|
432
478
|
// No agent discovered and no device picked yet — prompt the user
|
|
433
479
|
// to pick one of their machines (handles the non-LAN case where
|
package/src/auth.ts
CHANGED
|
@@ -410,8 +410,10 @@ export interface RemoteDevice {
|
|
|
410
410
|
runnerDown: boolean;
|
|
411
411
|
lastHeartbeat: number;
|
|
412
412
|
isGuest: boolean;
|
|
413
|
+
hostUserId?: string;
|
|
413
414
|
hostName?: string;
|
|
414
415
|
hostEmail?: string;
|
|
416
|
+
hostUserIdString?: string;
|
|
415
417
|
accessScope: 'owner' | 'shared-scoped' | 'shared-legacy';
|
|
416
418
|
quicHost: string;
|
|
417
419
|
quicPort: number;
|
|
@@ -433,6 +435,49 @@ export interface DeviceList {
|
|
|
433
435
|
shared: RemoteDevice[];
|
|
434
436
|
}
|
|
435
437
|
|
|
438
|
+
export interface GuestInvitation {
|
|
439
|
+
hostUserId: string;
|
|
440
|
+
hostName: string;
|
|
441
|
+
hostEmail: string;
|
|
442
|
+
hostUserIdString?: string;
|
|
443
|
+
createdAt: number;
|
|
444
|
+
expiresAt: number;
|
|
445
|
+
inviteCode?: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface ActiveGuestHost {
|
|
449
|
+
hostUserId: string;
|
|
450
|
+
hostName: string;
|
|
451
|
+
hostEmail: string;
|
|
452
|
+
grantedAt: number;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface GuestHostsResponse {
|
|
456
|
+
pending: GuestInvitation[];
|
|
457
|
+
active: ActiveGuestHost[];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export interface InvitationHostDevice {
|
|
461
|
+
deviceId: string;
|
|
462
|
+
name: string;
|
|
463
|
+
platform: string;
|
|
464
|
+
lastHeartbeat?: number;
|
|
465
|
+
proposed: boolean;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface InvitationPreview {
|
|
469
|
+
inviteCode: string;
|
|
470
|
+
hostUserId: string;
|
|
471
|
+
hostName: string;
|
|
472
|
+
hostEmail: string;
|
|
473
|
+
hostUserIdString?: string;
|
|
474
|
+
proposedDeviceIds?: string[];
|
|
475
|
+
hostDevices: InvitationHostDevice[];
|
|
476
|
+
invitedByUserId?: boolean;
|
|
477
|
+
expiresAt: number;
|
|
478
|
+
createdAt: number;
|
|
479
|
+
}
|
|
480
|
+
|
|
436
481
|
/**
|
|
437
482
|
* Fetch the set of remote dev machines this user can reach. Splits into
|
|
438
483
|
* owned (user is the host) vs shared (host invited them as a guest).
|
|
@@ -463,8 +508,10 @@ export async function listReachableDevices(
|
|
|
463
508
|
runnerDown: !!d.runnerDown,
|
|
464
509
|
lastHeartbeat: d.lastHeartbeat ?? 0,
|
|
465
510
|
isGuest: !!d.isGuest,
|
|
511
|
+
hostUserId: d.hostUserId,
|
|
466
512
|
hostName: d.hostName,
|
|
467
513
|
hostEmail: d.hostEmail,
|
|
514
|
+
hostUserIdString: d.hostUserIdString,
|
|
468
515
|
accessScope: d.accessScope ?? 'owner',
|
|
469
516
|
quicHost: d.quicHost ?? d.host ?? '',
|
|
470
517
|
quicPort: d.quicPort ?? 0,
|
|
@@ -488,3 +535,93 @@ export async function listReachableDevices(
|
|
|
488
535
|
return { owned: [], shared: [] };
|
|
489
536
|
}
|
|
490
537
|
}
|
|
538
|
+
|
|
539
|
+
export async function mintGuestSdkToken(
|
|
540
|
+
token: string,
|
|
541
|
+
hostUserId: string,
|
|
542
|
+
targetDeviceId: string,
|
|
543
|
+
): Promise<{ token: string; expiresAt: number; allowedProjects?: string[] }> {
|
|
544
|
+
const res = await fetch(`${convexSiteUrl}/guests/sdk-token`, {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: {
|
|
547
|
+
Authorization: `Bearer ${token}`,
|
|
548
|
+
'Content-Type': 'application/json',
|
|
549
|
+
},
|
|
550
|
+
body: JSON.stringify({ hostUserId, targetDeviceId }),
|
|
551
|
+
});
|
|
552
|
+
if (!res.ok) {
|
|
553
|
+
const data = await res.json().catch(() => ({}));
|
|
554
|
+
throw new Error(data.error || 'Failed to mint delegated SDK token');
|
|
555
|
+
}
|
|
556
|
+
return res.json();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function fetchGuestHosts(token: string): Promise<GuestHostsResponse> {
|
|
560
|
+
const res = await fetch(`${convexSiteUrl}/guests/hosts`, {
|
|
561
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
562
|
+
});
|
|
563
|
+
if (!res.ok) {
|
|
564
|
+
const data = await res.json().catch(() => ({}));
|
|
565
|
+
throw new Error(data.error || 'Failed to fetch guest hosts');
|
|
566
|
+
}
|
|
567
|
+
return res.json();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export async function findInviteByCode(
|
|
571
|
+
token: string,
|
|
572
|
+
code: string,
|
|
573
|
+
): Promise<InvitationPreview | null> {
|
|
574
|
+
const cleaned = code.toUpperCase().trim();
|
|
575
|
+
const res = await fetch(
|
|
576
|
+
`${convexSiteUrl}/guests/find-by-code?code=${encodeURIComponent(cleaned)}`,
|
|
577
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
578
|
+
);
|
|
579
|
+
if (res.status === 404) return null;
|
|
580
|
+
if (!res.ok) {
|
|
581
|
+
const data = await res.json().catch(() => ({}));
|
|
582
|
+
throw new Error(data.error || 'Failed to load invite');
|
|
583
|
+
}
|
|
584
|
+
return res.json();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export async function acceptGuestByCode(
|
|
588
|
+
token: string,
|
|
589
|
+
code: string,
|
|
590
|
+
approvedDeviceIds?: string[],
|
|
591
|
+
): Promise<{ hostName: string; hostEmail: string }> {
|
|
592
|
+
const res = await fetch(`${convexSiteUrl}/guests/accept-code`, {
|
|
593
|
+
method: 'POST',
|
|
594
|
+
headers: {
|
|
595
|
+
Authorization: `Bearer ${token}`,
|
|
596
|
+
'Content-Type': 'application/json',
|
|
597
|
+
},
|
|
598
|
+
body: JSON.stringify({
|
|
599
|
+
code: code.toUpperCase().trim(),
|
|
600
|
+
approvedDeviceIds,
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
if (!res.ok) {
|
|
604
|
+
const data = await res.json().catch(() => ({}));
|
|
605
|
+
throw new Error(data.error || 'Invalid invite code');
|
|
606
|
+
}
|
|
607
|
+
return res.json();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export async function acceptGuestInvitation(
|
|
611
|
+
token: string,
|
|
612
|
+
hostUserId: string,
|
|
613
|
+
approvedDeviceIds?: string[],
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
const res = await fetch(`${convexSiteUrl}/guests/accept`, {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
headers: {
|
|
618
|
+
Authorization: `Bearer ${token}`,
|
|
619
|
+
'Content-Type': 'application/json',
|
|
620
|
+
},
|
|
621
|
+
body: JSON.stringify({ hostUserId, approvedDeviceIds }),
|
|
622
|
+
});
|
|
623
|
+
if (!res.ok) {
|
|
624
|
+
const data = await res.json().catch(() => ({}));
|
|
625
|
+
throw new Error(data.error || 'Failed to accept invitation');
|
|
626
|
+
}
|
|
627
|
+
}
|