@entropy-softworks/ui 2026.7.31 → 2026.8.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/lib/components/button.d.ts +11 -2
  3. package/lib/components/button.d.ts.map +1 -1
  4. package/lib/components/button.jsx +14 -4
  5. package/lib/components/button.jsx.map +1 -1
  6. package/lib/context/AppConfigContext.d.ts +19 -0
  7. package/lib/context/AppConfigContext.d.ts.map +1 -1
  8. package/lib/context/AppConfigContext.jsx.map +1 -1
  9. package/lib/screens/auth/AccountPickerScreen.d.ts.map +1 -1
  10. package/lib/screens/auth/AccountPickerScreen.jsx +3 -2
  11. package/lib/screens/auth/AccountPickerScreen.jsx.map +1 -1
  12. package/lib/screens/auth/AuthScreen.d.ts +45 -1
  13. package/lib/screens/auth/AuthScreen.d.ts.map +1 -1
  14. package/lib/screens/auth/AuthScreen.jsx +69 -27
  15. package/lib/screens/auth/AuthScreen.jsx.map +1 -1
  16. package/lib/screens/auth/ConsentScreen.d.ts.map +1 -1
  17. package/lib/screens/auth/ConsentScreen.jsx +2 -2
  18. package/lib/screens/auth/ConsentScreen.jsx.map +1 -1
  19. package/lib/screens/auth/ErrorScreen.d.ts.map +1 -1
  20. package/lib/screens/auth/ErrorScreen.jsx +2 -2
  21. package/lib/screens/auth/ErrorScreen.jsx.map +1 -1
  22. package/lib/screens/auth/ExpiredLinkScreen.d.ts.map +1 -1
  23. package/lib/screens/auth/ExpiredLinkScreen.jsx +2 -2
  24. package/lib/screens/auth/ExpiredLinkScreen.jsx.map +1 -1
  25. package/lib/screens/auth/ForgotPasswordScreen.d.ts.map +1 -1
  26. package/lib/screens/auth/ForgotPasswordScreen.jsx +7 -6
  27. package/lib/screens/auth/ForgotPasswordScreen.jsx.map +1 -1
  28. package/lib/screens/auth/ImportDataScreen.d.ts.map +1 -1
  29. package/lib/screens/auth/ImportDataScreen.jsx +1 -1
  30. package/lib/screens/auth/ImportDataScreen.jsx.map +1 -1
  31. package/lib/screens/auth/LoginScreen.d.ts.map +1 -1
  32. package/lib/screens/auth/LoginScreen.jsx +7 -6
  33. package/lib/screens/auth/LoginScreen.jsx.map +1 -1
  34. package/lib/screens/auth/MfaChallengeScreen.d.ts.map +1 -1
  35. package/lib/screens/auth/MfaChallengeScreen.jsx +3 -2
  36. package/lib/screens/auth/MfaChallengeScreen.jsx.map +1 -1
  37. package/lib/screens/auth/MfaEnrollScreen.d.ts.map +1 -1
  38. package/lib/screens/auth/MfaEnrollScreen.jsx +9 -8
  39. package/lib/screens/auth/MfaEnrollScreen.jsx.map +1 -1
  40. package/lib/screens/auth/PasskeyEnrollScreen.d.ts.map +1 -1
  41. package/lib/screens/auth/PasskeyEnrollScreen.jsx +2 -2
  42. package/lib/screens/auth/PasskeyEnrollScreen.jsx.map +1 -1
  43. package/lib/screens/auth/PrivacyPolicyScreen.d.ts.map +1 -1
  44. package/lib/screens/auth/PrivacyPolicyScreen.jsx +3 -2
  45. package/lib/screens/auth/PrivacyPolicyScreen.jsx.map +1 -1
  46. package/lib/screens/auth/RegisterScreen.d.ts.map +1 -1
  47. package/lib/screens/auth/RegisterScreen.jsx +6 -5
  48. package/lib/screens/auth/RegisterScreen.jsx.map +1 -1
  49. package/lib/screens/auth/ResetPasswordScreen.d.ts.map +1 -1
  50. package/lib/screens/auth/ResetPasswordScreen.jsx +6 -5
  51. package/lib/screens/auth/ResetPasswordScreen.jsx.map +1 -1
  52. package/lib/screens/auth/ServerConfigScreen.d.ts.map +1 -1
  53. package/lib/screens/auth/ServerConfigScreen.jsx +6 -5
  54. package/lib/screens/auth/ServerConfigScreen.jsx.map +1 -1
  55. package/lib/screens/auth/TermsOfServiceScreen.d.ts.map +1 -1
  56. package/lib/screens/auth/TermsOfServiceScreen.jsx +3 -2
  57. package/lib/screens/auth/TermsOfServiceScreen.jsx.map +1 -1
  58. package/lib/screens/auth/VerifyEmailScreen.d.ts.map +1 -1
  59. package/lib/screens/auth/VerifyEmailScreen.jsx +5 -4
  60. package/lib/screens/auth/VerifyEmailScreen.jsx.map +1 -1
  61. package/lib/screens/auth/WelcomeScreen.d.ts.map +1 -1
  62. package/lib/screens/auth/WelcomeScreen.jsx +2 -2
  63. package/lib/screens/auth/WelcomeScreen.jsx.map +1 -1
  64. package/lib/services/auth.service.d.ts +9 -0
  65. package/lib/services/auth.service.d.ts.map +1 -1
  66. package/lib/services/auth.service.js.map +1 -1
  67. package/lib/services/index.d.ts +1 -0
  68. package/lib/services/index.d.ts.map +1 -1
  69. package/lib/services/index.js +2 -0
  70. package/lib/services/index.js.map +1 -1
  71. package/lib/services/telemetry.d.ts +30 -0
  72. package/lib/services/telemetry.d.ts.map +1 -0
  73. package/lib/services/telemetry.js +72 -0
  74. package/lib/services/telemetry.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/components/button.tsx +32 -1
  77. package/src/context/AppConfigContext.tsx +19 -0
  78. package/src/screens/auth/AccountPickerScreen.tsx +4 -2
  79. package/src/screens/auth/AuthScreen.tsx +159 -31
  80. package/src/screens/auth/ConsentScreen.tsx +4 -2
  81. package/src/screens/auth/ErrorScreen.tsx +4 -2
  82. package/src/screens/auth/ExpiredLinkScreen.tsx +4 -2
  83. package/src/screens/auth/ForgotPasswordScreen.tsx +10 -6
  84. package/src/screens/auth/ImportDataScreen.tsx +2 -1
  85. package/src/screens/auth/LoginScreen.tsx +10 -6
  86. package/src/screens/auth/MfaChallengeScreen.tsx +3 -2
  87. package/src/screens/auth/MfaEnrollScreen.tsx +15 -8
  88. package/src/screens/auth/PasskeyEnrollScreen.tsx +4 -2
  89. package/src/screens/auth/PrivacyPolicyScreen.tsx +4 -2
  90. package/src/screens/auth/RegisterScreen.tsx +8 -5
  91. package/src/screens/auth/ResetPasswordScreen.tsx +9 -5
  92. package/src/screens/auth/ServerConfigScreen.tsx +6 -5
  93. package/src/screens/auth/TermsOfServiceScreen.tsx +4 -2
  94. package/src/screens/auth/VerifyEmailScreen.tsx +8 -4
  95. package/src/screens/auth/WelcomeScreen.tsx +4 -2
  96. package/src/services/__tests__/telemetry.test.ts +81 -0
  97. package/src/services/auth.service.ts +9 -0
  98. package/src/services/index.ts +9 -0
  99. package/src/services/telemetry.ts +107 -0
@@ -70,7 +70,8 @@ export function PasskeyEnrollScreen({
70
70
  <CardFooter className="flex-col gap-3 border-t-0">
71
71
  <Button
72
72
  className="w-full"
73
- onPress={onEnroll}
73
+ telemetryId="auth.passkey_enroll.enroll"
74
+ onPress={onEnroll}
74
75
  isLoading={isLoading}
75
76
  accessibilityLabel="Add a passkey"
76
77
  accessibilityRole="button"
@@ -80,7 +81,8 @@ export function PasskeyEnrollScreen({
80
81
  <Button
81
82
  className="w-full"
82
83
  variant="ghost"
83
- onPress={onSkip}
84
+ telemetryId="auth.passkey_enroll.skip"
85
+ onPress={onSkip}
84
86
  accessibilityLabel="Not now"
85
87
  accessibilityRole="button"
86
88
  >
@@ -1,3 +1,4 @@
1
+ import { withClickTracking } from "../../services/telemetry";
1
2
  import React from "react";
2
3
  import { Linking, ScrollView, Text, View } from "react-native";
3
4
  import { SafeAreaView } from "react-native-safe-area-context";
@@ -61,7 +62,8 @@ export function PrivacyPolicyScreen({
61
62
  <View className="flex-1" style={{ backgroundColor: isDark ? "#000000" : "#FFFFFF" }}>
62
63
  <View className="px-6 pt-4 pb-6">
63
64
  <View className="flex-row items-center justify-between mb-6">
64
- <IconButton variant="ghost" size="sm" onPress={() => router.back()}>
65
+ <IconButton variant="ghost" size="sm" telemetryId="auth.privacy_policy.back"
66
+ onPress={() => router.back()}>
65
67
  <Ionicons name="arrow-back" size={20} color={isDark ? "#FFFFFF" : "#000000"} />
66
68
  </IconButton>
67
69
 
@@ -189,7 +191,7 @@ export function PrivacyPolicyScreen({
189
191
  <Text
190
192
  className="font-mono text-sm underline"
191
193
  style={{ color: isDark ? "#6BA3E5" : "#4A90E2" }}
192
- onPress={() => Linking.openURL(`mailto:${supportEmail}`)}
194
+ onPress={withClickTracking("auth.privacy_policy.open_u_r_l", () => Linking.openURL(`mailto:${supportEmail}`))}
193
195
  >
194
196
  {supportEmail}
195
197
  </Text>
@@ -1,3 +1,4 @@
1
+ import { withClickTracking } from "../../services/telemetry";
1
2
  import React, { useEffect, useMemo, useState } from "react";
2
3
  import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
3
4
  import { SafeAreaView } from "react-native-safe-area-context";
@@ -232,7 +233,8 @@ export function RegisterScreen({
232
233
  <IconButton
233
234
  variant="ghost"
234
235
  size="sm"
235
- onPress={() => router.push(routes.welcome)}
236
+ telemetryId="auth.register.push"
237
+ onPress={() => router.push(routes.welcome)}
236
238
  accessibilityLabel="Go back"
237
239
  accessibilityRole="button"
238
240
  >
@@ -361,7 +363,7 @@ export function RegisterScreen({
361
363
  I agree to the{" "}
362
364
  <Text
363
365
  className="text-sm underline"
364
- onPress={() => router.push(routes.termsOfService)}
366
+ onPress={withClickTracking("auth.register.push_2", () => router.push(routes.termsOfService))}
365
367
  accessibilityRole="link"
366
368
  accessibilityLabel="Terms of Service"
367
369
  >
@@ -370,7 +372,7 @@ export function RegisterScreen({
370
372
  and{" "}
371
373
  <Text
372
374
  className="text-sm underline"
373
- onPress={() => router.push(routes.privacyPolicy)}
375
+ onPress={withClickTracking("auth.register.push_3", () => router.push(routes.privacyPolicy))}
374
376
  accessibilityRole="link"
375
377
  accessibilityLabel="Privacy Policy"
376
378
  >
@@ -386,7 +388,7 @@ export function RegisterScreen({
386
388
  Already have an account?{" "}
387
389
  <Text
388
390
  className="text-sm text-foreground underline"
389
- onPress={() => router.push(routes.login)}
391
+ onPress={withClickTracking("auth.register.push_4", () => router.push(routes.login))}
390
392
  accessibilityRole="link"
391
393
  accessibilityLabel="Sign in"
392
394
  >
@@ -399,7 +401,8 @@ export function RegisterScreen({
399
401
  <Button
400
402
  className="w-full"
401
403
  isLoading={isLoading}
402
- onPress={handleRegister}
404
+ telemetryId="auth.register.register"
405
+ onPress={handleRegister}
403
406
  disabled={Platform.OS === "web" && !email && !password}
404
407
  accessibilityLabel="Create account"
405
408
  accessibilityRole="button"
@@ -17,6 +17,7 @@ import {
17
17
  CardHeader,
18
18
  CardTitle,
19
19
  } from "../../components/card";
20
+ import { withClickTracking } from "../../services/telemetry";
20
21
  import { Input, PasswordInput } from "../../components/input";
21
22
  import PasswordStrengthMeter from "../../components/auth/PasswordStrengthMeter";
22
23
  import PwnedPasswordWarning from "../../components/auth/PwnedPasswordWarning";
@@ -228,7 +229,8 @@ export function ResetPasswordScreen({ encryptionWarning }: ResetPasswordScreenPr
228
229
  <View className="mt-4 self-start absolute top-4 left-6">
229
230
  <IconButton
230
231
  variant="ghost"
231
- onPress={() => (step === "password" ? setStep("code") : router.back())}
232
+ telemetryId="auth.reset_password.step"
233
+ onPress={() => (step === "password" ? setStep("code") : router.back())}
232
234
  accessibilityLabel="Go back"
233
235
  accessibilityRole="button"
234
236
  >
@@ -270,7 +272,8 @@ export function ResetPasswordScreen({ encryptionWarning }: ResetPasswordScreenPr
270
272
 
271
273
  <Button
272
274
  className="w-full"
273
- onPress={handleCodeSubmit}
275
+ telemetryId="auth.reset_password.code_submit"
276
+ onPress={handleCodeSubmit}
274
277
  isLoading={isLoading}
275
278
  accessibilityLabel="Continue"
276
279
  accessibilityRole="button"
@@ -282,7 +285,7 @@ export function ResetPasswordScreen({ encryptionWarning }: ResetPasswordScreenPr
282
285
  Back to{" "}
283
286
  <Text
284
287
  className="text-sm text-foreground underline"
285
- onPress={() => router.push(routes.login)}
288
+ onPress={withClickTracking("auth.reset_password.push", () => router.push(routes.login))}
286
289
  accessibilityRole="link"
287
290
  accessibilityLabel="Sign in"
288
291
  >
@@ -347,7 +350,8 @@ export function ResetPasswordScreen({ encryptionWarning }: ResetPasswordScreenPr
347
350
 
348
351
  <Button
349
352
  className="w-full"
350
- onPress={handlePasswordSubmit}
353
+ telemetryId="auth.reset_password.password_submit"
354
+ onPress={handlePasswordSubmit}
351
355
  isLoading={isLoading}
352
356
  accessibilityLabel="Reset password"
353
357
  accessibilityRole="button"
@@ -358,7 +362,7 @@ export function ResetPasswordScreen({ encryptionWarning }: ResetPasswordScreenPr
358
362
  <MutedText className="text-center text-sm">
359
363
  <Text
360
364
  className="text-sm text-foreground underline"
361
- onPress={() => setStep("code")}
365
+ onPress={withClickTracking("auth.reset_password.step_2", () => setStep("code"))}
362
366
  accessibilityRole="link"
363
367
  accessibilityLabel="Use a different code"
364
368
  >
@@ -8,6 +8,7 @@ import {
8
8
  TextInput,
9
9
  View,
10
10
  } from "react-native";
11
+ import { withClickTracking } from "../../services/telemetry";
11
12
  import { SafeAreaView } from "react-native-safe-area-context";
12
13
  import { useNavigation } from "../../context/NavigationContext";
13
14
  import { StatusBar } from "expo-status-bar";
@@ -243,7 +244,7 @@ export function ServerConfigScreen({
243
244
  <View className="px-6 py-6">
244
245
  <View className="mb-8 flex-row items-center justify-between">
245
246
  <Pressable
246
- onPress={() => router.back()}
247
+ onPress={withClickTracking("auth.server_config.back", () => router.back())}
247
248
  accessibilityRole="button"
248
249
  accessibilityLabel="Go back"
249
250
  className="active:scale-98 h-10 w-10 items-center justify-center rounded-lg bg-secondary"
@@ -305,7 +306,7 @@ export function ServerConfigScreen({
305
306
  </View>
306
307
 
307
308
  <Pressable
308
- onPress={testConnection}
309
+ onPress={withClickTracking("auth.server_config.test_connection", testConnection)}
309
310
  disabled={isTestingConnection}
310
311
  accessibilityRole="button"
311
312
  accessibilityLabel="Test connection"
@@ -330,7 +331,7 @@ export function ServerConfigScreen({
330
331
  </Pressable>
331
332
 
332
333
  <Pressable
333
- onPress={handleUseDefault}
334
+ onPress={withClickTracking("auth.server_config.use_default", handleUseDefault)}
334
335
  accessibilityRole="button"
335
336
  accessibilityLabel="Use default server"
336
337
  className="active:scale-98 mb-3 w-full rounded-lg border border-border bg-background px-6 py-4"
@@ -344,7 +345,7 @@ export function ServerConfigScreen({
344
345
  </Pressable>
345
346
 
346
347
  <Pressable
347
- onPress={handleClearConfiguration}
348
+ onPress={withClickTracking("auth.server_config.clear_configuration", handleClearConfiguration)}
348
349
  accessibilityRole="button"
349
350
  accessibilityLabel="Clear all settings"
350
351
  className="active:scale-98 mb-3 w-full rounded-lg border border-destructive bg-background px-6 py-3"
@@ -358,7 +359,7 @@ export function ServerConfigScreen({
358
359
  </Pressable>
359
360
 
360
361
  <Pressable
361
- onPress={handleSave}
362
+ onPress={withClickTracking("auth.server_config.save", handleSave)}
362
363
  accessibilityRole="button"
363
364
  accessibilityLabel="Save configuration"
364
365
  className="active:scale-98 mb-6 w-full rounded-lg bg-primary px-6 py-4"
@@ -1,3 +1,4 @@
1
+ import { withClickTracking } from "../../services/telemetry";
1
2
  import React from "react";
2
3
  import { Linking, ScrollView, Text, View } from "react-native";
3
4
  import { SafeAreaView } from "react-native-safe-area-context";
@@ -73,7 +74,8 @@ export function TermsOfServiceScreen({
73
74
  <View className="flex-1" style={{ backgroundColor: isDark ? "#000000" : "#FFFFFF" }}>
74
75
  <View className="px-6 pt-4 pb-6">
75
76
  <View className="flex-row items-center justify-between mb-6">
76
- <IconButton variant="ghost" size="sm" onPress={() => router.back()}>
77
+ <IconButton variant="ghost" size="sm" telemetryId="auth.terms_of_service.back"
78
+ onPress={() => router.back()}>
77
79
  <Ionicons name="arrow-back" size={20} color={isDark ? "#FFFFFF" : "#000000"} />
78
80
  </IconButton>
79
81
 
@@ -166,7 +168,7 @@ export function TermsOfServiceScreen({
166
168
  <Text
167
169
  className="font-mono text-sm underline"
168
170
  style={{ color: isDark ? "#6BA3E5" : "#4A90E2" }}
169
- onPress={() => Linking.openURL(`mailto:${supportEmail}`)}
171
+ onPress={withClickTracking("auth.terms_of_service.open_u_r_l", () => Linking.openURL(`mailto:${supportEmail}`))}
170
172
  >
171
173
  {supportEmail}
172
174
  </Text>
@@ -18,6 +18,7 @@ import {
18
18
  CardHeader,
19
19
  CardTitle,
20
20
  } from "../../components/card";
21
+ import { withClickTracking } from "../../services/telemetry";
21
22
  import { Input } from "../../components/input";
22
23
  import AuthService from "../../services/auth.service";
23
24
  import { useAuth } from "../../hooks/useAuth";
@@ -223,7 +224,8 @@ export function VerifyEmailScreen() {
223
224
  <View className="mt-4 self-start absolute top-4 left-6">
224
225
  <IconButton
225
226
  variant="ghost"
226
- onPress={() => logout()}
227
+ telemetryId="auth.verify_email.logout"
228
+ onPress={() => logout()}
227
229
  accessibilityLabel="Sign out"
228
230
  accessibilityRole="button"
229
231
  >
@@ -268,7 +270,7 @@ export function VerifyEmailScreen() {
268
270
  ) : (
269
271
  <Text
270
272
  className="text-base text-foreground underline"
271
- onPress={handleResend}
273
+ onPress={withClickTracking("auth.verify_email.resend", handleResend)}
272
274
  accessibilityRole="button"
273
275
  accessibilityLabel="Resend verification code"
274
276
  >
@@ -281,7 +283,8 @@ export function VerifyEmailScreen() {
281
283
  <CardFooter className="flex-col items-center gap-1 border-t-0 pt-0">
282
284
  <Button
283
285
  className="h-14 w-full"
284
- onPress={handleVerify}
286
+ telemetryId="auth.verify_email.verify"
287
+ onPress={handleVerify}
285
288
  isLoading={isLoading}
286
289
  accessibilityLabel="Continue"
287
290
  accessibilityRole="button"
@@ -294,7 +297,8 @@ export function VerifyEmailScreen() {
294
297
  <Button
295
298
  variant="ghost"
296
299
  className="w-full"
297
- onPress={() => logout()}
300
+ telemetryId="auth.verify_email.logout_2"
301
+ onPress={() => logout()}
298
302
  accessibilityLabel="Sign out"
299
303
  accessibilityRole="button"
300
304
  >
@@ -50,10 +50,12 @@ export function WelcomeScreen({ decoration, topAccessory }: WelcomeScreenProps)
50
50
  ) : null}
51
51
 
52
52
  <View className="w-full max-w-[360px] gap-3">
53
- <Button className="w-full" onPress={() => router.push(routes.login)}>
53
+ <Button className="w-full" telemetryId="auth.welcome.push"
54
+ onPress={() => router.push(routes.login)}>
54
55
  Sign In
55
56
  </Button>
56
- <Button variant="outline" className="w-full" onPress={() => router.push(routes.register)}>
57
+ <Button variant="outline" className="w-full" telemetryId="auth.welcome.push_2"
58
+ onPress={() => router.push(routes.register)}>
57
59
  Create Account
58
60
  </Button>
59
61
  </View>
@@ -0,0 +1,81 @@
1
+ import {
2
+ __resetTelemetryForTests,
3
+ registerTelemetry,
4
+ trackClick,
5
+ withClickTracking,
6
+ } from "../telemetry";
7
+
8
+ describe("telemetry registry", () => {
9
+ afterEach(() => __resetTelemetryForTests());
10
+
11
+ it("is a no-op until a sink is registered", () => {
12
+ // The whole point of the default: a consumer that never opts in must not
13
+ // have this throw, buffer, or reach the network.
14
+ expect(() => trackClick("login.submit")).not.toThrow();
15
+ });
16
+
17
+ it("forwards element and route to the registered sink", () => {
18
+ const sink = { trackClick: jest.fn() };
19
+ registerTelemetry(sink);
20
+
21
+ trackClick("login.submit", "/login");
22
+
23
+ expect(sink.trackClick).toHaveBeenCalledWith("login.submit", "/login");
24
+ });
25
+
26
+ it("swallows a sink that throws", () => {
27
+ // A failing sink must never take down the press handler it is observing.
28
+ registerTelemetry({
29
+ trackClick: () => {
30
+ throw new Error("collector unreachable");
31
+ },
32
+ });
33
+
34
+ expect(() => trackClick("login.submit")).not.toThrow();
35
+ });
36
+
37
+ it("stops reporting once the sink is removed", () => {
38
+ const sink = { trackClick: jest.fn() };
39
+ registerTelemetry(sink);
40
+ registerTelemetry(null);
41
+
42
+ trackClick("login.submit");
43
+
44
+ expect(sink.trackClick).not.toHaveBeenCalled();
45
+ });
46
+
47
+ describe("withClickTracking", () => {
48
+ it("returns the original handler when there is no id", () => {
49
+ // Identity, not a wrapper: an untagged control should not pay for a
50
+ // closure on every render.
51
+ const handler = jest.fn();
52
+ expect(withClickTracking(undefined, handler)).toBe(handler);
53
+ });
54
+
55
+ it("reports before invoking the handler", () => {
56
+ const order: string[] = [];
57
+ registerTelemetry({ trackClick: () => order.push("tracked") });
58
+ const wrapped = withClickTracking("login.submit", () => order.push("handled"));
59
+
60
+ wrapped?.();
61
+
62
+ expect(order).toEqual(["tracked", "handled"]);
63
+ });
64
+
65
+ it("still reports when there is no handler to call", () => {
66
+ const sink = { trackClick: jest.fn() };
67
+ registerTelemetry(sink);
68
+
69
+ withClickTracking("login.submit", undefined)?.();
70
+
71
+ expect(sink.trackClick).toHaveBeenCalledWith("login.submit", undefined);
72
+ });
73
+
74
+ it("passes arguments and the return value through", () => {
75
+ registerTelemetry({ trackClick: jest.fn() });
76
+ const wrapped = withClickTracking("row.select", (id: never) => `picked ${String(id)}`);
77
+
78
+ expect(wrapped?.("abc" as never)).toBe("picked abc");
79
+ });
80
+ });
81
+ });
@@ -98,6 +98,15 @@ export interface User {
98
98
  mfa_enabled?: boolean;
99
99
  mfa_verified?: boolean;
100
100
  created_at?: string;
101
+ /**
102
+ * Tenant the account belongs to. Only the multi-tenant IdP (access) sends
103
+ * these two; the single-tenant products have no tenant and carry a single
104
+ * `role` rather than a role set.
105
+ */
106
+ tenant_id?: string;
107
+ tenant_slug?: string;
108
+ /** Effective role names, as resolved by the IdP (direct, assigned, or via a group). */
109
+ roles?: string[];
101
110
  }
102
111
 
103
112
  export interface AuthResponse {
@@ -50,3 +50,12 @@ export {
50
50
  resolveIcon,
51
51
  type ResolvedIcon,
52
52
  } from "./favicon";
53
+
54
+ // Telemetry registry. No-op until a host app calls registerTelemetry.
55
+ export {
56
+ registerTelemetry,
57
+ trackClick,
58
+ withClickTracking,
59
+ __resetTelemetryForTests,
60
+ type TelemetrySink,
61
+ } from "./telemetry";
@@ -0,0 +1,107 @@
1
+ // Telemetry registry.
2
+ //
3
+ // This package deliberately knows nothing about how telemetry is collected,
4
+ // batched, authenticated or shipped. It only knows that an interaction
5
+ // happened and what it was called. The consuming app registers a sink at
6
+ // startup — the same arrangement as `configureApi` for HTTP — so one app can
7
+ // post to its own server, another to a collector, and a third to nothing at
8
+ // all.
9
+ //
10
+ // Nothing is registered by default, and every entry point is a no-op until
11
+ // something is. An app that never calls `registerTelemetry` behaves exactly as
12
+ // it did before this existed, which is what makes it safe to ship in a package
13
+ // that self-hosted deployments consume.
14
+
15
+ /** What a host app must provide to receive interaction events. */
16
+ export interface TelemetrySink {
17
+ /**
18
+ * An interactive element was activated.
19
+ *
20
+ * `element` is the author-assigned, stable id — not label text, which
21
+ * changes with copy and can carry user data.
22
+ */
23
+ trackClick(element: string, route?: string): void;
24
+ }
25
+
26
+ class TelemetryStore {
27
+ private sink: TelemetrySink | null = null;
28
+
29
+ register(sink: TelemetrySink | null): void {
30
+ this.sink = sink;
31
+ }
32
+
33
+ trackClick(element: string, route?: string): void {
34
+ // Never allowed to throw into a press handler. A telemetry sink that
35
+ // fails must not stop the button from doing its job.
36
+ try {
37
+ this.sink?.trackClick(element, route);
38
+ } catch {
39
+ // Intentionally swallowed.
40
+ }
41
+ }
42
+
43
+ reset(): void {
44
+ this.sink = null;
45
+ }
46
+ }
47
+
48
+ const store = new TelemetryStore();
49
+
50
+ /**
51
+ * Install a telemetry sink. Call once at app startup; pass `null` to remove.
52
+ */
53
+ export function registerTelemetry(sink: TelemetrySink | null): void {
54
+ store.register(sink);
55
+ }
56
+
57
+ /**
58
+ * Report an element activation. Safe to call unconditionally — it is a no-op
59
+ * when no sink is registered.
60
+ */
61
+ export function trackClick(element: string, route?: string): void {
62
+ store.trackClick(element, route);
63
+ }
64
+
65
+ /**
66
+ * Wrap a press handler so activating the element is reported first.
67
+ *
68
+ * Used by the primitives to implement `telemetryId`, and exported so app code
69
+ * can tag anything the primitives do not cover.
70
+ */
71
+ export function withClickTracking<F extends (...args: never[]) => unknown>(
72
+ element: string | undefined,
73
+ handler: F,
74
+ ): F;
75
+ export function withClickTracking<F extends (...args: never[]) => unknown>(
76
+ element: string | undefined,
77
+ handler: F | null | undefined,
78
+ ): F | undefined;
79
+ /**
80
+ * Wrap a press handler so activating the element is reported first.
81
+ *
82
+ * Used by the primitives to implement `telemetryId`, and exported so app code
83
+ * can tag anything the primitives do not cover.
84
+ *
85
+ * Overloaded so the wrapper is exactly as optional as the handler it wraps: a
86
+ * required `onPress` stays required, and React Native's nullable `onPress` is
87
+ * accepted without every call site having to coalesce it.
88
+ */
89
+ export function withClickTracking(
90
+ element: string | undefined,
91
+ handler: ((...args: never[]) => unknown) | null | undefined,
92
+ ): ((...args: never[]) => unknown) | undefined {
93
+ // Untagged controls keep their original handler rather than gaining a
94
+ // closure per render for nothing.
95
+ if (element === undefined) {
96
+ return handler ?? undefined;
97
+ }
98
+ return (...args: never[]) => {
99
+ trackClick(element);
100
+ return handler?.(...args);
101
+ };
102
+ }
103
+
104
+ /** Test seam: drop the registered sink so cases cannot leak into each other. */
105
+ export function __resetTelemetryForTests(): void {
106
+ store.reset();
107
+ }