@terreno/mcp 57.19.2 → 57.20.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.
Files changed (197) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/README.md +32 -269
  3. package/dist/bootstrap.d.ts +12 -0
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +77 -122
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
  8. package/dist/docs/resources/overview.md +4 -2
  9. package/dist/docs/resources/patterns.md +237 -176
  10. package/dist/docs/resources/ui.md +48 -17
  11. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  12. package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  13. package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  14. package/dist/docs/upgrades/57.20.0.md +238 -0
  15. package/dist/docs/versioned/next/approval-rubric.md +30 -12
  16. package/dist/docs/versioned/next/explanation/README.md +5 -0
  17. package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
  18. package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
  19. package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  20. package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  21. package/dist/docs/versioned/next/explanation/authentication.md +45 -1
  22. package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
  23. package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
  24. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
  25. package/dist/docs/versioned/next/explanation/organizations.md +37 -0
  26. package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
  27. package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
  28. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  29. package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
  30. package/dist/docs/versioned/next/how-to/README.md +7 -2
  31. package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
  32. package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  33. package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  34. package/dist/docs/versioned/next/how-to/circleci.md +15 -5
  35. package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  36. package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  37. package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  38. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  39. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  40. package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
  41. package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  42. package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  43. package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  44. package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  45. package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  46. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  47. package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  48. package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  49. package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  50. package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  51. package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  52. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  53. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  54. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  55. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  56. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  57. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  58. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  59. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  60. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  61. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  62. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  63. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  64. package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  65. package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  66. package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  67. package/dist/docs/versioned/next/reference/README.md +1 -0
  68. package/dist/docs/versioned/next/reference/account-security.md +700 -0
  69. package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
  70. package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
  71. package/dist/docs/versioned/next/reference/api.md +461 -35
  72. package/dist/docs/versioned/next/reference/cli.md +1 -1
  73. package/dist/docs/versioned/next/reference/comms.md +8 -0
  74. package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
  75. package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
  76. package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
  77. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  78. package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
  79. package/dist/docs/versioned/next/reference/syncdb.md +8 -1
  80. package/dist/docs/versioned/next/reference/ui.md +1271 -27
  81. package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  82. package/dist/docs/versioned/next/tutorials/README.md +4 -0
  83. package/dist/httpApp.d.ts +14 -0
  84. package/dist/httpApp.d.ts.map +1 -0
  85. package/dist/httpApp.js +99 -0
  86. package/dist/httpApp.js.map +1 -0
  87. package/dist/index.js +8 -90
  88. package/dist/index.js.map +1 -1
  89. package/dist/local/localTools.d.ts.map +1 -1
  90. package/dist/local/localTools.js +44 -14
  91. package/dist/local/localTools.js.map +1 -1
  92. package/dist/local/metro/metroDevSession.d.ts +18 -0
  93. package/dist/local/metro/metroDevSession.d.ts.map +1 -1
  94. package/dist/local/metro/metroDevSession.js +42 -3
  95. package/dist/local/metro/metroDevSession.js.map +1 -1
  96. package/dist/local/tools/databaseQuery.d.ts.map +1 -1
  97. package/dist/local/tools/databaseQuery.js +3 -13
  98. package/dist/local/tools/databaseQuery.js.map +1 -1
  99. package/dist/local/tools/databaseSchema.d.ts.map +1 -1
  100. package/dist/local/tools/databaseSchema.js +3 -13
  101. package/dist/local/tools/databaseSchema.js.map +1 -1
  102. package/dist/local/tools/localMongo.d.ts +10 -0
  103. package/dist/local/tools/localMongo.d.ts.map +1 -0
  104. package/dist/local/tools/localMongo.js +30 -0
  105. package/dist/local/tools/localMongo.js.map +1 -0
  106. package/dist/local/tools/runtime.d.ts +18 -2
  107. package/dist/local/tools/runtime.d.ts.map +1 -1
  108. package/dist/local/tools/runtime.js +75 -20
  109. package/dist/local/tools/runtime.js.map +1 -1
  110. package/dist/local/tools/syncDb.d.ts +11 -0
  111. package/dist/local/tools/syncDb.d.ts.map +1 -1
  112. package/dist/local/tools/syncDb.js +15 -3
  113. package/dist/local/tools/syncDb.js.map +1 -1
  114. package/dist/rateLimit.d.ts +35 -0
  115. package/dist/rateLimit.d.ts.map +1 -0
  116. package/dist/rateLimit.js +112 -0
  117. package/dist/rateLimit.js.map +1 -0
  118. package/dist/tools.d.ts.map +1 -1
  119. package/dist/tools.js +42 -60
  120. package/dist/tools.js.map +1 -1
  121. package/package.json +4 -3
  122. package/src/docs/guidelines/ui/guidelines/core.md +1 -1
  123. package/src/docs/resources/overview.md +4 -2
  124. package/src/docs/resources/patterns.md +237 -176
  125. package/src/docs/resources/ui.md +48 -17
  126. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  127. package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  128. package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  129. package/src/docs/upgrades/57.20.0.md +238 -0
  130. package/src/docs/versioned/next/approval-rubric.md +30 -12
  131. package/src/docs/versioned/next/explanation/README.md +5 -0
  132. package/src/docs/versioned/next/explanation/account-linking.md +169 -0
  133. package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
  134. package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  135. package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  136. package/src/docs/versioned/next/explanation/authentication.md +45 -1
  137. package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
  138. package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
  139. package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
  140. package/src/docs/versioned/next/explanation/organizations.md +37 -0
  141. package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
  142. package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
  143. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  144. package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
  145. package/src/docs/versioned/next/how-to/README.md +7 -2
  146. package/src/docs/versioned/next/how-to/audit-log.md +22 -3
  147. package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  148. package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  149. package/src/docs/versioned/next/how-to/circleci.md +15 -5
  150. package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  151. package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  152. package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  153. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  154. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  155. package/src/docs/versioned/next/how-to/password-reset.md +1 -1
  156. package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  157. package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  158. package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  159. package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  160. package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  161. package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  162. package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  163. package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  164. package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  165. package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  166. package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  167. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  168. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  169. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  170. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  171. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  172. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  173. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  174. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  175. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  176. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  177. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  178. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  179. package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  180. package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  181. package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  182. package/src/docs/versioned/next/reference/README.md +1 -0
  183. package/src/docs/versioned/next/reference/account-security.md +700 -0
  184. package/src/docs/versioned/next/reference/admin-backend.md +3 -1
  185. package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
  186. package/src/docs/versioned/next/reference/api.md +461 -35
  187. package/src/docs/versioned/next/reference/cli.md +1 -1
  188. package/src/docs/versioned/next/reference/comms.md +8 -0
  189. package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
  190. package/src/docs/versioned/next/reference/environment-variables.md +9 -3
  191. package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
  192. package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  193. package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
  194. package/src/docs/versioned/next/reference/syncdb.md +8 -1
  195. package/src/docs/versioned/next/reference/ui.md +1271 -27
  196. package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  197. package/src/docs/versioned/next/tutorials/README.md +4 -0
@@ -4,15 +4,16 @@ React Native UI component library (a large component library). Layout (Box, Page
4
4
 
5
5
  ## Key exports
6
6
 
7
- - Layout: `Box`, `Page`, `SplitPage`, `Card`, `ChartCard`, `DashboardGrid`, `DashboardGridItem`
8
- - Forms: `TextField`, `SelectField`, `DateTimeField`, `CheckBox`
7
+ - Layout: `Box`, `Page`, `ListDetailLayout`, `SplitPage`, `Card`, `ChartCard`, `DashboardGrid`, `DashboardGridItem`
8
+ - Forms: `TextField`, `SelectField`, `DateTimeField`, `CheckBox`, `OtpField`, `FormContainer`
9
9
  - Display: `Text`, `Heading`, `Badge`, `DataTable`, `Scorecard`, `SparklineChart`, `LineChart`, `BarChart`, `AreaChart`, `DonutChart`
10
10
  - Actions: `Button`, `IconButton`, `Link`
11
11
  - Feedback: `Spinner`, `Modal`, `Toast`
12
12
  - Loading: `Skeleton`, `SkeletonText`, `SkeletonCircle`, `SkeletonCard`, `SkeletonList`, `SkeletonGroup`, `withLoading`, `LoadingProps`
13
13
  - Notifications: `NotificationBell`, `NotificationInbox`, `NotificationPreferences`
14
14
  - AI chat: `GPTChat`, `AskCard` (agent asks in the transcript), `SimpleAskCard` (an ask's simple card for narrow layouts), `BlocksView` (a whole-reply YAML document)
15
- - Authentication: `SocialLoginButton`, `LoginScreen`, `SignUpScreen`
15
+ - Authentication: `SocialLoginButton`, `LoginScreen`, `TwoFactorChallengeScreen`, `SignUpScreen`
16
+ - Account security: `LinkedAccountsCard`, `PasswordCard`, `PasskeysCard`, `SessionsCard`, `TwoFactorSettingsCard`, `AccountSecurityScreen`
16
17
  - Theming: `TerrenoProvider`, `useTheme`, custom icon registry (`icons` prop)
17
18
  - **Type re-exports:** `StyleProp`, `ViewStyle` (re-exported from react-native to avoid version conflicts)
18
19
 
@@ -452,6 +453,102 @@ overrides it:
452
453
  | Helper | `{testID}.helper` |
453
454
  | Show/hide toggle | `{testID}.visibility-toggle` |
454
455
 
456
+ ### TextField accessibilityLabel
457
+
458
+ `accessibilityLabel` sets the input's accessible name (`aria-label`). Without it screen readers
459
+ announce "Text input field", so pass it when the visible `title` is missing or not specific.
460
+
461
+ ### TextField autoComplete
462
+
463
+ `autoComplete` is the field's autofill hint: `"on"`, `"off"`, `"username"`, `"email"`,
464
+ `"current-password"`, `"new-password"`, `"one-time-code"`, or `"username webauthn"`.
465
+
466
+ On web it is written to the input's `autocomplete` attribute. On native it is sent as React
467
+ Native's `autoComplete` (Android's autofill hint) with the matching iOS `textContentType`:
468
+
469
+ | `autoComplete` | Android `autoComplete` | iOS `textContentType` |
470
+ | --- | --- | --- |
471
+ | `"username"`, `"username webauthn"` | `username` | `username` |
472
+ | `"email"` | `email` | `emailAddress` |
473
+ | `"current-password"` | `current-password` (password) | `password` |
474
+ | `"new-password"` | `new-password` (new password) | `newPassword` (iOS offers a strong password) |
475
+ | `"one-time-code"` | `one-time-code` (SMS code) | `oneTimeCode` |
476
+ | `"off"` | `off` | `none` |
477
+ | `"on"` or unset | not set | from `type`: `emailAddress` for `email`, `password` for `password`, `username` for `username`, else `none` |
478
+
479
+ Passkey autofill (`"username webauthn"`) is web only; native gets the plain username hint.
480
+
481
+ - `"off"` turns autofill off for the field. Use `"username"` on the email field of a login or
482
+ sign-up form, so password managers save it with the password.
483
+ - `Field type="password"` defaults to `"current-password"`. Pass `autoComplete="new-password"`
484
+ (directly or through `TapToEdit`) on fields that set a new password, so password managers
485
+ offer to generate one.
486
+ - `SignUpScreen` password fields default to `"new-password"`; `LoginScreen` password fields
487
+ default to `"current-password"`. Both default the email field to `"username"`.
488
+ - `"username webauthn"` asks the browser to offer saved passkeys in the field's autofill;
489
+ `LoginScreen` sets it on the email field when `passkeyAutofill` is on.
490
+
491
+ ### FormContainer
492
+
493
+ Groups a form's fields. On web it renders a real `<form>` around them, so browsers and password
494
+ managers pair a username with its password, autofill saved credentials, and offer to save new
495
+ ones. react-native-web never renders a `<form>` on its own, and Chrome logs "Password field is not
496
+ contained in a form" for every password input outside one. On native it renders a `Box`.
497
+
498
+ ``````typescript
499
+ import {Button, FormContainer, type FormContainerHandle, TextField} from "@terreno/ui";
500
+
501
+ const formRef = useRef<FormContainerHandle>(null);
502
+ const passwordRef = useRef<TextInput | null>(null);
503
+
504
+ <FormContainer accessibilityLabel="Sign in" gap={4} onSubmit={signIn} ref={formRef}>
505
+ <TextField
506
+ autoComplete="username"
507
+ blurOnSubmit={false}
508
+ onChange={setEmail}
509
+ onEnter={() => passwordRef.current?.focus()}
510
+ returnKeyType="next"
511
+ title="Email"
512
+ type="email"
513
+ value={email}
514
+ />
515
+ <TextField
516
+ autoComplete="current-password"
517
+ inputRef={(ref) => {
518
+ passwordRef.current = ref;
519
+ }}
520
+ onChange={setPassword}
521
+ onEnter={() => formRef.current?.requestSubmit()}
522
+ returnKeyType="go"
523
+ title="Password"
524
+ type="password"
525
+ value={password}
526
+ />
527
+ <Button onClick={() => formRef.current?.requestSubmit()} text="Log In" />
528
+ </FormContainer>
529
+ ``````
530
+
531
+ | Prop | Type | Behavior |
532
+ | --- | --- | --- |
533
+ | `onSubmit` | `() => void \| Promise<void>` | Called on `ref.requestSubmit()` and, on web, when the browser submits the form. The page reload is always prevented. |
534
+ | `ref` | `Ref<FormContainerHandle>` | `requestSubmit(): Promise<void>`. On web it calls the form element's `requestSubmit()`, so the browser fires a real submit event (password managers watch for it to offer "Save password") and the submit handler calls `onSubmit`; it resolves when `onSubmit` settles. On native, and in browsers without `requestSubmit`, it calls `onSubmit` directly. |
535
+ | `username` | `string` | For forms without a username field, such as change password: on web a hidden, read-only `autocomplete="username"` input names the account so password managers update its saved password. Ignored on native. |
536
+ | `accessibilityLabel` | `string` | Names the form; on web a named form is a `form` landmark. |
537
+ | Box layout props | | `gap`, `padding`, `width`, and the rest of `Box`'s layout props apply to the inner `Box`. |
538
+
539
+ The `<form>` uses `display: contents` and `noValidate`, so it adds no layout box and no browser
540
+ validation bubbles. `TextField` stops the browser's implicit Enter submission (it handles Enter
541
+ through `onEnter`), so Enter never submits twice: give earlier fields `returnKeyType="next"`, an
542
+ `onEnter` that focuses the next field, and `blurOnSubmit={false}`, and call
543
+ `ref.requestSubmit()` from the last field and the submit button. A `FormContainer` inside another
544
+ one renders a `Box`, since HTML forms cannot nest; its `requestSubmit` calls its own `onSubmit`.
545
+
546
+ `LoginScreen`, `SignUpScreen`, `PasswordCard`, the password step of `TwoFactorSettingsCard`, and
547
+ `GPTChat`'s API key modal already use it.
548
+
549
+ **Test IDs:** the form is `{testID}` on web and the `Box` is `{testID}` on native; the hidden
550
+ username input is `{testID}-username`.
551
+
455
552
  ### DropdownPanel
456
553
 
457
554
  `DropdownPanel` is the compositional dropdown behind the DataTable filter popovers. A
@@ -965,8 +1062,96 @@ Types: `SimpleAskCardProps`, and `SimpleCard` and `SimpleCardButton` from `@terr
965
1062
  story: `SimpleAskCard`. Its demo answers a plan card on a watch-sized 198×242 pt screen, and its
966
1063
  "Every fixture" story draws every valid fixture's card at that size.
967
1064
 
1065
+ ### ListDetailLayout
1066
+
1067
+ Route-driven list/detail layout, such as a mail inbox. Use it as a route group's `_layout.tsx`.
1068
+ On a phone it is an expo-router `Stack`: the group's index route shows the list, and opening an
1069
+ item pushes the detail route with the native animation and swipe-back. When the layout is at least
1070
+ `splitAtWidth` wide, the list stays in a left pane and the `Stack` shows the detail beside it.
1071
+
1072
+ The width is the layout's own measured width, so it decides correctly inside a sidebar or an admin
1073
+ rail. Before the first layout event, it uses the window width. The `Stack` stays in the same place
1074
+ in the tree at every width, so it never remounts when a tablet rotates or a browser resizes, and
1075
+ the open item stays open: crossing the breakpoint makes no router call. The
1076
+ `list` element does move between the index screen and the left pane, so it remounts at the
1077
+ breakpoint and its scroll position resets; keep list state such as filters or search text in the route or a store. The list pane
1078
+ has no `Stack` header, so it applies safe-area insets on its top, left, and bottom edges.
1079
+
1080
+ | Prop | Type | Default | Description |
1081
+ | --- | --- | --- | --- |
1082
+ | `list` | `ReactElement` | required | The list. Rendered in the left pane when split, and by `ListDetailIndex` when not |
1083
+ | `detailHref` | `(id: string) => Href` | required | Where selecting an item navigates |
1084
+ | `indexHref` | `string` | required | The group's own path, such as `/mail` |
1085
+ | `emptyDetail` | `ReactElement` | none | Shown in the detail pane when split and nothing is open |
1086
+ | `splitAtWidth` | `number` | `768` | Split when the layout is at least this many pixels wide |
1087
+ | `listWidth` | `number` | `360` | Width of the list pane when split |
1088
+ | `detailRoute` | `string` | `"[id]"` | The child route name that holds the detail |
1089
+ | `paramName` | `string` | `"id"` | The route param that holds the item id |
1090
+ | `screenOptions` | `Stack` `screenOptions` | none | Passed through to the `Stack` |
1091
+ | `children` | `Stack.Screen` elements | none | Passed through to the `Stack` |
1092
+
1093
+ `ListDetailIndex` is the group's `index.tsx` screen. It renders `list` when stacked, and
1094
+ `emptyDetail` when the list already sits in the left pane.
1095
+
1096
+ `useListDetail()` returns `{isSplit, selectedId, select, clear}` and throws outside a
1097
+ `ListDetailLayout`.
1098
+
1099
+ - `selectedId` is the `paramName` param of the route directly below `indexHref` whose segment is
1100
+ `detailRoute`. It is `undefined` on the index route, and an `id` from a parent dynamic segment
1101
+ never counts.
1102
+ - `select(id)` pushes `detailHref(id)`. When split with another item open, it replaces the open
1103
+ detail instead, so the stack is never deeper than the list plus one detail and back always
1104
+ returns to the list. When split, selecting the open item again does nothing.
1105
+ - `clear()` calls `router.dismissTo(indexHref)` when an item is open, so it pops back to the
1106
+ index inside this `Stack` and never leaves the group. It does nothing when no item is open.
1107
+
1108
+ When split, the list is already on screen, so the header's back never returns to it: `Stack`
1109
+ screens get `headerBackVisible: false`, `animation: "none"`, and a `headerLeft` back button
1110
+ (`list-detail-leave`) that leaves the group through the parent navigator, or nothing when the
1111
+ parent cannot go back. The web header ignores `headerBackVisible`, so `headerLeft` draws it. Your
1112
+ `screenOptions` win where they set the same key. A static web export writes a detail URL only when
1113
+ `[id].tsx` exports `generateStaticParams` for that id; other ids need the host's `index.html`
1114
+ fallback. Mark the open row with
1115
+ `accessibilityState={{selected: true}}` so screen readers announce it (a clickable `Box` row sends
1116
+ it as `aria-current="true"` on web). See
1117
+ [Build a list/detail screen](../how-to/build-list-detail-screens.md).
1118
+
1119
+ | Element | testID |
1120
+ | --- | --- |
1121
+ | Layout root | `list-detail-layout` |
1122
+ | List pane (split only) | `list-detail-list-pane` |
1123
+ | Detail pane, which holds the `Stack` | `list-detail-detail-pane` |
1124
+ | Empty detail (split, nothing open) | `list-detail-empty` |
1125
+
1126
+ ```typescript
1127
+ // app/mail/_layout.tsx
1128
+ import {ListDetailIndex, ListDetailLayout, Text} from "@terreno/ui";
1129
+
1130
+ export const unstable_settings = {initialRouteName: "index"};
1131
+
1132
+ const MailLayout: React.FC = () => (
1133
+ <ListDetailLayout
1134
+ detailHref={(id) => `/mail/${id}`}
1135
+ emptyDetail={<Text>Select a message</Text>}
1136
+ indexHref="/mail"
1137
+ list={<MailList />}
1138
+ />
1139
+ );
1140
+ export default MailLayout;
1141
+
1142
+ // app/mail/index.tsx
1143
+ const MailIndex: React.FC = () => <ListDetailIndex />;
1144
+ export default MailIndex;
1145
+
1146
+ // app/mail/[id].tsx is an ordinary route screen that reads its id with useLocalSearchParams.
1147
+ ```
1148
+
968
1149
  ### SplitPage
969
1150
 
1151
+ Prefer [`ListDetailLayout`](#listdetaillayout) for new list/detail screens: it keeps the selected
1152
+ item in the route, so deep links and back navigation work, and it splits on native tablets.
1153
+ SplitPage remains supported for list/detail views that do not have their own routes.
1154
+
970
1155
  Master-detail layout. Pass `listViewData` plus `renderListViewItem` for the list, and
971
1156
  `renderContent` or children for the detail pane. On large screens both panes stay visible. On small
972
1157
  screens the detail replaces the list until the user goes back.
@@ -1039,6 +1224,110 @@ On web, that container is portaled to `document.body` (`position: fixed`, `z-ind
1039
1224
 
1040
1225
  Only the visible toast takes clicks. Every full-width layer around it (the container, its safe area, and the `Toast` layout wrapper) uses `pointerEvents: "box-none"`, so a button at the same height as an open toast stays clickable. Declare that style through `StyleSheet.create`; react-native-web drops `pointerEvents` from inline styles.
1041
1226
 
1227
+ ## Consent components
1228
+
1229
+ Client side of `ConsentApp` ([API reference](api.md#consent-forms-consentapp),
1230
+ [concepts](../explanation/consent-forms.md)). Lazily exported from the package root.
1231
+
1232
+ ### ConsentNavigator and ConsentFormScreen
1233
+
1234
+ `ConsentNavigator` wraps app content, fetches `GET /consents/pending` through
1235
+ `useConsentForms(api, baseUrl)`, and renders `ConsentFormScreen` for each pending form until
1236
+ the list is empty. `ConsentFormScreen` shows the form's markdown (with `{{variable}}`
1237
+ substitution), required checkboxes, an optional signature, the scroll-to-bottom gate, and the
1238
+ agree and decline buttons.
1239
+
1240
+ When staff signed the form provisionally, the pending form carries
1241
+ `provisionalSignature: {signedAt, signedByName}` (`ConsentFormPublic`), and the screen shows
1242
+ "Signed on your behalf by <name> on <date>" above the content (`testID`
1243
+ `consent-form-provisional-notice`, date in Luxon `DATETIME_MED` for the screen's `locale`). The user still agrees or
1244
+ declines themselves: the scroll gate, checkboxes, and signature work as for any other form.
1245
+ The signer's note is never shown.
1246
+
1247
+ `useConsentForms.ts` also exports two helpers the consent components share:
1248
+ `applyConsentVariables(content, variables?)` replaces `{{name}}` placeholders and leaves
1249
+ unknown ones as written, and `resolveConsentLocale(form, locale)` picks the content locale to
1250
+ show: the exact locale, then its language (`en-GB` → `en`), then the form's default.
1251
+
1252
+ ### ProvisionalConsentSigner and useProvisionalConsent
1253
+
1254
+ For staff who record consent on a user's behalf. The server must grant the viewer
1255
+ `consentResponse:signOnBehalf` (or `user.admin` when `ConsentApp` has no `accessControl`).
1256
+
1257
+ ```tsx
1258
+ import {ProvisionalConsentSigner} from "@terreno/ui";
1259
+
1260
+ <ProvisionalConsentSigner api={terrenoApi} userId={memberId} />
1261
+ ```
1262
+
1263
+ | Prop | Type | Description |
1264
+ | --- | --- | --- |
1265
+ | `api` | RTK Query api | The consumer api, as passed to `ConsentNavigator` |
1266
+ | `baseUrl` | `string` | Optional prefix for consent routes |
1267
+ | `userId` | `string` | The user being signed for |
1268
+ | `onSigned` | `(response) => void` | Called with the stored `ConsentResponse` after a provisional signature |
1269
+ | `onError` | `(error) => void` | Called with the server error when signing fails |
1270
+ | `variables` | `Record<string, string>` | Values for `{{name}}` placeholders in form content |
1271
+
1272
+ It lists every form that applies to the user (`GET /consent-responses/status`) with a status
1273
+ badge: "Pending", "Signed provisionally by <signer> on <date>", "Signed by user on <date>",
1274
+ or "Declined by user on <date>". It shows a spinner while loading and the server error with
1275
+ a Retry button on failure. Lazily exported from the package root.
1276
+
1277
+ Pending forms have a "Sign on behalf" button. Declined forms do not: the user's own decline
1278
+ always decides the status, so a provisional signature would not count. It opens the form read-only
1279
+ (`MarkdownView`, `variables` applied, in the viewer's locale, then that locale's language,
1280
+ then the form's default) with a required "Note" box. "Sign provisionally" stays disabled until
1281
+ the note has text. Submitting sends `{userId, consentFormId, note, locale}` to
1282
+ `POST /consent-responses/provisional`, then returns to the list with a confirmation naming
1283
+ the signer and time, and calls `onSigned`. A server error shows under the note and calls
1284
+ `onError`. The signer never ticks the form's checkboxes or draws a signature: those stay the
1285
+ user's.
1286
+
1287
+ Provisional, signed, and declined rows have a "Print" button, and so does the confirmation
1288
+ after signing. It calls `printConsentHtml` with `buildConsentPdfHtml` HTML: the form content,
1289
+ its status, and, when there is a provisional signature, the "Provisional signature" block with
1290
+ the signer, date, and note. Provisional rows also have "Void". It opens a dialog that asks for
1291
+ a required reason, sends it to `POST /consent-responses/:id/void`, and refetches, so the row
1292
+ returns to "Pending". A void error shows in the dialog and calls `onError`.
1293
+
1294
+ `useProvisionalConsent({api, baseUrl?, userId})` returns `{statuses, isLoading, error,
1295
+ refetch, sign, isSigning, voidSignature, isVoiding}`. `statuses` are `ProvisionalConsentStatusRow`s
1296
+ (`{form, status, isConsented, respondedAt?, provisional?}`). `sign({consentFormId, note,
1297
+ locale?})` signs for the hook's `userId` and resolves to the `ConsentResponse`, or rejects
1298
+ with the RTK error. `voidSignature({responseId, reason})` voids a provisional signature the
1299
+ same way. The hook injects `getConsentStatus`, `signConsentProvisionally`, and
1300
+ `voidProvisionalConsent` once per `(api, baseUrl)`. Signing and voiding invalidate the
1301
+ `ConsentStatus` and `PendingConsents` tags.
1302
+
1303
+ testIDs: `provisional-consent-signer`, `provisional-consent-loading`,
1304
+ `provisional-consent-error`, `provisional-consent-retry-button`, `provisional-consent-empty`,
1305
+ `provisional-consent-row-{formId}`, `provisional-consent-status-{formId}`,
1306
+ `provisional-consent-open-{formId}`, `provisional-consent-form-content`,
1307
+ `provisional-consent-note-input`, `provisional-consent-submit-button`,
1308
+ `provisional-consent-cancel-button`, `provisional-consent-submit-error`,
1309
+ `provisional-consent-success`, `provisional-consent-success-print`,
1310
+ `provisional-consent-print-{formId}`, `provisional-consent-void-{formId}`,
1311
+ `provisional-consent-void-modal` (confirm button `provisional-consent-void-modal.primary`),
1312
+ `provisional-consent-void-reason-input`, `provisional-consent-void-error`.
1313
+
1314
+ ### Consent printouts
1315
+
1316
+ `buildConsentPdfHtml(data)` builds a self-contained HTML document for a consent response
1317
+ (response details, user info, checkboxes, audit trail, signature, content snapshot). Native
1318
+ apps turn it into a PDF with `sharePdfFromHtml({html, filename})`.
1319
+
1320
+ `printConsentHtml({html})` opens the print dialog for that HTML: `expo-print` `printAsync`
1321
+ on native, and on web a hidden `iframe` (`srcdoc`) whose window is printed, so the app page
1322
+ itself is not. The iframe is removed after printing, or at the next print if the browser
1323
+ never reports that printing finished, so consent HTML does not linger in the page. The
1324
+ promise rejects if printing throws.
1325
+
1326
+ `PdfTemplateData.provisionalSignature?: {signedAt, signedByName, note?, signerIp?, voidedAt?}`
1327
+ adds a "Provisional signature" section with the signer and date (ISO dates are printed with
1328
+ Luxon `DATETIME_FULL`), the escaped note and signer IP when given, and "Voided <date>" when
1329
+ voided. Pass `note` and `signerIp` only on admin printouts: the user never sees the note.
1330
+
1042
1331
  ## Authentication Components
1043
1332
 
1044
1333
  ### SocialLoginButton
@@ -1049,14 +1338,19 @@ Branded social login buttons for OAuth authentication with Google, GitHub, and A
1049
1338
  import {SocialLoginButton} from "@terreno/ui";
1050
1339
  import {authClient} from "@/store/authClient";
1051
1340
 
1341
+ // The Better Auth client resolves {data, error} rather than throwing, and SocialLoginButton only
1342
+ // logs a rejected onPress, so show the error yourself.
1052
1343
  <SocialLoginButton
1053
1344
  provider="google" // "google" | "github" | "apple"
1054
1345
  variant="primary" // "primary" | "outline"
1055
1346
  onPress={async () => {
1056
- await authClient.signIn.social({
1347
+ const {error} = await authClient.signIn.social({
1057
1348
  provider: "google",
1058
1349
  callbackURL: "yourapp://auth/callback",
1059
1350
  });
1351
+ if (error) {
1352
+ setError(error.message ?? "Couldn't sign in with Google.");
1353
+ }
1060
1354
  }}
1061
1355
  loading={isLoading}
1062
1356
  fullWidth
@@ -1067,30 +1361,231 @@ import {authClient} from "@/store/authClient";
1067
1361
  - Proper brand colors for each provider (follows brand guidelines)
1068
1362
  - Built-in icons (FontAwesome 6)
1069
1363
  - Primary and outline variants
1070
- - Loading states with spinner
1364
+ - Loading states with spinner; the spinner follows later changes to the `loading` prop
1071
1365
  - Automatic text: "Continue with {Provider}"
1072
1366
 
1073
1367
  ### LoginScreen
1074
1368
 
1075
- Complete login screen with email/password and optional social providers.
1369
+ Presentational login screen: email/password fields, branded and generic OAuth providers,
1370
+ passkeys, a last-used badge, one-tap "Continue" cards, and a notice slot. It renders the methods
1371
+ and accounts it is given and reports presses through callbacks; it never calls an auth client
1372
+ itself. Without the optional props below it renders the plain email/password form.
1076
1373
 
1077
1374
  ``````typescript
1078
1375
  import {LoginScreen} from "@terreno/ui";
1079
- import {authClient} from "@/store/authClient";
1376
+
1377
+ // The Better Auth client resolves {data, error} rather than throwing, and LoginScreen does not
1378
+ // catch a rejected onSubmit, so put the error in state and pass it as `error`.
1379
+ // signInWithSocial and the other handlers below are app helpers that do the same.
1380
+ const [error, setError] = useState<string | undefined>(undefined);
1080
1381
 
1081
1382
  <LoginScreen
1082
- onEmailLogin={async ({email, password}) => {
1083
- await authClient.signIn.email({email, password});
1383
+ error={error}
1384
+ fields={[
1385
+ {name: "email", label: "Email", type: "email", required: true},
1386
+ {name: "password", label: "Password", type: "password", required: true},
1387
+ ]}
1388
+ onSubmit={async ({email = "", password = ""}) => {
1389
+ setError(undefined);
1390
+ const {error: signInError} = await authClient.signIn.email({email, password});
1391
+ if (signInError) {
1392
+ setError(signInError.message ?? "Couldn't sign in.");
1393
+ }
1084
1394
  }}
1085
- onSocialLogin={async (provider) => {
1086
- await authClient.signIn.social({provider, callbackURL: "yourapp://auth"});
1395
+ oauthProviders={[
1396
+ {provider: "google", onPress: () => signInWithSocial("google")},
1397
+ {provider: "github", onPress: () => signInWithSocial("github"), loading: isGitHubLoading},
1398
+ ]}
1399
+ genericProviders={[
1400
+ {id: "mock-oidc", label: "Mock OIDC", iconName: "key", onPress: () => signInWithSocial("mock-oidc")},
1401
+ ]}
1402
+ lastUsedMethod="github" // provider id, "email", or "passkey"
1403
+ onPasskeyPress={signInWithPasskey}
1404
+ passkeyAutofill // web: email field gets autocomplete="username webauthn"
1405
+ currentSessions={deviceSessions} // signed-in sessions on this device
1406
+ rememberedAccounts={remembered} // accounts remembered from earlier sign-ins
1407
+ onContinue={(account, source) => (source === "session" ? switchTo(account) : reauth(account))}
1408
+ onForget={(account) => forget(account.id)}
1409
+ notice={{tone: "warning", text: "Sign in with Google to link GitHub.", actionLabel: "Continue with Google", onAction: () => signInWithSocial("google")}}
1410
+ onForgotPassword={() => router.push("/forgot-password")}
1411
+ onSignUpPress={() => router.push("/signup")}
1412
+ />
1413
+ ``````
1414
+
1415
+ | Prop | Type | Behavior |
1416
+ | --- | --- | --- |
1417
+ | `fields` | `LoginFieldConfig[]` | Form fields; `onSubmit` receives their values keyed by `name`. An empty array leaves out the email form, the Sign in button, and the forgot-password link. Method pills stay. |
1418
+ | `oauthProviders` | `OAuthProviderConfig[]` | Branded Google, GitHub, and Apple pills (`SocialLoginButton` outline). |
1419
+ | `genericProviders` | `GenericOAuthProviderConfig[]` | Providers without a brand, such as a generic OIDC provider: `{id, label, onPress, loading?, disabled?, iconName?}`. Each renders an outline pill "Continue with {label}" after the branded ones. |
1420
+ | `lastUsedMethod` | `string` | The method this device signed in with last. It gets a "Last used" pill. A provider moves to the front of the provider list; `"passkey"` leads the method stack; `"email"` pills the email label. Methods always sit above the email form. A method the screen does not offer is ignored. |
1421
+ | `lastUsedLabel` | `string` | Pill text. Defaults to "Last used". |
1422
+ | `currentSessions` | `LoginAccountOption[]` | Sessions already signed in on this device. Each renders a continue row; one tap calls `onContinue(account, "session")`. A session reads "Signed in · {email}". |
1423
+ | `rememberedAccounts` | `LoginAccountOption[]` | Accounts remembered on this device. Each renders a continue row (`onContinue(account, "remembered")`). With `onForget`, Edit reveals Remove. Sessions cannot be removed here. |
1424
+ | `onContinue` / `onForget` | callbacks | Rows render only when `onContinue` is set. Remove (`{testID}-forget-{index}`) renders only in Edit mode. |
1425
+ | `passwordError` | `string` | Wrong email or password, shown under the password field. A banner `error` is for everything else. |
1426
+ | `continuingAccount` / `onCancelContinue` | `LoginAccountOption` / `() => void` | Replaces the body with the password step for a remembered email account. Back and "Not you?" call `onCancelContinue`. |
1427
+ | `initialMethodsExpanded` | `boolean` | Returning users start with other methods behind "Sign in another way". Set this to open them. |
1428
+ | `onPasskeyPress` | `() => void \| Promise<void>` | Shows a "Sign in with a passkey" button (text via `passkeyButtonText`). |
1429
+ | `passkeyAutofill` | `boolean` | On web, sets the email field's `autocomplete` to `username webauthn` so browsers offer saved passkeys (conditional mediation). Ignored on native. |
1430
+ | `notice` | `LoginNotice` | `{tone: "info" \| "warning" \| "error", title?, text, actionLabel?, onAction?, focusMethod?}`, one banner under the heading. Warning and error are `role="alert"`. Info is `role="status"` and can be dismissed. `notice` wins over `error`. |
1431
+ | `initialValues` | `Record<string, string>` | Values the form starts with, keyed by field `name`. Passing a new object later replaces those fields and keeps what the user typed in the others, so a "Continue" card can prefill the email. |
1432
+
1433
+ Accessibility: the control a "Last used" pill marks gets the pill in its accessible name
1434
+ ("Continue with GitHub, last used"; for email, the email field and the Sign in button). A
1435
+ `warning` or `error` notice has `role="alert"` and an `info` notice `role="status"`. A session
1436
+ row reads "Signed in · {email}". A remembered row reads "{Provider} · {masked email}".
1437
+
1438
+ Keyboard and autofill: the fields sit in a `FormContainer` (`{testID}-form`), so on web they are
1439
+ in a real `<form>` that password managers recognize. The email field defaults to
1440
+ `autoComplete="username"` (or `"username webauthn"` with `passkeyAutofill` on web) and password
1441
+ fields to `"current-password"`; each input's accessible name is its field `label`. Enter in a
1442
+ field moves focus to the next field (`returnKeyType="next"`); Enter in the last field
1443
+ (`returnKeyType="go"`) submits once when Sign in is enabled, and does nothing while `loading` or
1444
+ while a required field is empty. Enter and the Sign in button both submit through the form's
1445
+ `requestSubmit()`, so browsers see a real submit and offer to save the password. A second submit
1446
+ while `onSubmit` is pending is ignored. Method pills sit above the form. From the email field,
1447
+ Tab reaches forgot password, the password field, its show/hide button, Sign in, then sign up.
1448
+
1449
+ `LoginAccountOption` is `{id, name?, email?, image?, method}`. Name, email, and image are
1450
+ optional because apps remember only the method by default; a row without them reads
1451
+ "Continue with {method}". Show identity only when the app opted in to remembering it. A remembered
1452
+ email is masked (`j***@n***.io`).
1453
+
1454
+ The submit button reads "Sign in", and "Signing in" while `loading`. The footer is "New here?"
1455
+ plus `signUpLinkText` (default "Create an account"). With accounts on the device, the title
1456
+ defaults to "Welcome back"; otherwise "Sign in". One divider, "or sign in another way", appears
1457
+ when a returning user expands methods. One divider, "or use your email", appears only when both
1458
+ methods and the email form are on screen.
1459
+
1460
+ **Test IDs** (with the default `testID="login-screen"`):
1461
+
1462
+ | Element | testID |
1463
+ | --- | --- |
1464
+ | Branded or generic provider button | `login-screen-oauth-{provider}` (e.g. `login-screen-oauth-github`, `login-screen-oauth-mock-oidc`) |
1465
+ | Last-used badge on a method | `login-screen-last-used-{method}` |
1466
+ | Last-used badge on a continue row | `login-screen-last-used-account-{index}` |
1467
+ | Continue row (sessions first, then remembered accounts) | `login-screen-continue-{index}` |
1468
+ | Remove, only while Edit is open | `login-screen-forget-{index}` (same index as its row) |
1469
+ | Edit / Done | `login-screen-accounts-edit` |
1470
+ | Sign in another way | `login-screen-another-way` |
1471
+ | Passkey button | `login-screen-passkey-button` |
1472
+ | Notice / its action / dismiss | `login-screen-notice` / `login-screen-notice-action` / `login-screen-notice-dismiss` |
1473
+ | Banner from the `error` prop | `login-screen-error` |
1474
+ | Wrong password, under the password field | `login-screen-password-input.error` |
1475
+ | Password step back / Not you? | `login-screen-continue-back` / `login-screen-continue-not-you` |
1476
+ | Continue-as card list | `login-screen-accounts` |
1477
+
1478
+ `OAuthButtons`, which `LoginScreen` and `SignUpScreen` use, takes the same `genericProviders`, a
1479
+ `lastUsed={{provider, label?, testID?}}` pill, and `dividerPosition` (`"above"` by default,
1480
+ `"below"` when the buttons sit above a form, `"none"` for no divider). `appearance` is `"brand"`
1481
+ (filled buttons, the sign-up default) or `"pill"` (neutral outline, what `LoginScreen` uses).
1482
+
1483
+ ### OtpField
1484
+
1485
+ One-time code input with one cell per digit. Typing fills the cells left to right, pasting or
1486
+ autofilling a whole code into any cell fills every cell, and backspace in an empty cell deletes
1487
+ the previous digit and moves back. `onComplete(code)` fires when every cell holds a digit.
1488
+ Non-digits are dropped, so a pasted `123 456` or `123-456` works.
1489
+
1490
+ ``````typescript
1491
+ import {OtpField} from "@terreno/ui";
1492
+
1493
+ <OtpField
1494
+ autoFocus
1495
+ length={6} // default 6
1496
+ value={code} // optional; omit to let the field keep its own state
1497
+ onChange={setCode}
1498
+ onComplete={(code) => verifyTotp({code})}
1499
+ errorText={error}
1500
+ testID="verify-otp"
1501
+ />
1502
+ ``````
1503
+
1504
+ Each cell is its own input. Every cell carries the one-time-code autofill hint
1505
+ (`autocomplete="one-time-code"` on web, `textContentType="oneTimeCode"` on iOS, `"sms-otp"` on
1506
+ Android) and a numeric keyboard, and a full-length code that lands in any cell fills the whole
1507
+ field, so platform code suggestions work whichever cell has focus. A full code pasted into a
1508
+ filled cell drops that cell's old digit. Tapping a cell past the first empty one moves focus back
1509
+ to that empty cell. Only the cell that takes the next digit is a tab stop (roving `tabIndex`), so
1510
+ Tab and Shift+Tab move past the field in one step.
1511
+
1512
+ `onEnter(code)` is called with the digits entered so far when the user presses Enter in a cell
1513
+ (the cells' `enterKeyHint` is `"done"`).
1514
+
1515
+ Accessibility: the cells sit in a `role="group"` named by `accessibilityLabel` (default
1516
+ "Verification code"); each cell is labelled "Digit k of N". With `errorText`, cells get
1517
+ `aria-invalid`.
1518
+
1519
+ **Test IDs:** the group is `{testID}`; cells are `{testID}-cell-{k}` with `k` from 1 to
1520
+ `length`; error, title, and helper are `{testID}-error`, `{testID}-title`, `{testID}-helper`.
1521
+
1522
+ ### TwoFactorChallengeScreen
1523
+
1524
+ Presentational second sign-in step for two-factor authentication. TOTP mode shows an `OtpField`
1525
+ and submits on its own once the code is complete; a toggle switches to a backup-code text field
1526
+ (backup codes contain letters). It reports `onSubmit({code, kind, trustDevice})` and never calls
1527
+ an auth client itself.
1528
+
1529
+ ``````typescript
1530
+ import {TwoFactorChallengeScreen} from "@terreno/ui";
1531
+
1532
+ // The Better Auth client resolves {data, error} rather than throwing; show its error through the
1533
+ // `error` prop (a rejected onSubmit only clears the code).
1534
+ const [error, setError] = useState<string | undefined>(undefined);
1535
+
1536
+ <TwoFactorChallengeScreen
1537
+ error={error} // e.g. "That code didn't work"; a new error clears the code
1538
+ loading={isVerifying}
1539
+ onCancel={() => router.replace("/login")}
1540
+ onSubmit={async ({code, kind, trustDevice}) => {
1541
+ setError(undefined);
1542
+ const result =
1543
+ kind === "totp"
1544
+ ? await authClient.twoFactor.verifyTotp({code, trustDevice})
1545
+ : await authClient.twoFactor.verifyBackupCode({code, trustDevice});
1546
+ if (result.error) {
1547
+ setError(result.error.message ?? "That code didn't work.");
1548
+ }
1087
1549
  }}
1088
- socialProviders={["google", "github", "apple"]}
1089
- onForgotPassword={() => navigation.navigate("ForgotPassword")}
1090
- onSignUp={() => navigation.navigate("SignUp")}
1550
+ trustDeviceDays={30}
1091
1551
  />
1092
1552
  ``````
1093
1553
 
1554
+ | Prop | Type | Behavior |
1555
+ | --- | --- | --- |
1556
+ | `onSubmit` | `(values: TwoFactorChallengeSubmission) => void \| Promise<void>` | Receives `{code, kind: "totp" \| "backup", trustDevice}`. Backup codes are trimmed. Not called while `loading` or while an earlier call is still pending. A rejection is caught, logged with `console.warn`, and clears the code. |
1557
+ | `error` | `string` | Shown under the cells for an authenticator code (`role="alert"`) and under the field for a backup code. The screen shows this string as passed, including a rate limit or an expired session. The entered code clears (and the first cell refocuses) when a new error arrives, and when a submission finishes (`loading` goes from true to false, or `onSubmit` settles) with an error set, so the same error twice still clears it. |
1558
+ | `loading` | `boolean` | Disables every control and shows a spinner on Verify. |
1559
+ | `trustDeviceDays` | `number` | Shows a "Trust this device" checkbox, checked by default for an authenticator code, with helper text "We won't ask for a code here for N days." Unset hides it and `trustDevice` is always `false`. |
1560
+ | `onCancel` / `cancelText` | `() => void` / `string` | Shows a Back link and a footer link (default "Sign in a different way"). |
1561
+ | `codeLength` | `number` | Digits in a TOTP code. Defaults to 6. |
1562
+ | `initialKind` | `"totp" \| "backup"` | The mode the screen opens in. Defaults to `"totp"`. The title follows the mode: "Enter your code" or "Use a backup code". |
1563
+ | `initialTrustDevice` | `boolean` | Whether "Trust this device" starts checked. Defaults to checked for an authenticator code and unchecked for a backup code. Pass `false` to start unchecked. |
1564
+ | `autoFocus` | `boolean` | Focuses the first code cell on mount. Defaults to `true`. Set `false` where the screen is a preview rather than the page's task, since the focus scrolls the page to the cells; a rejected code still refocuses the first cell. |
1565
+ | `title` / `accountLabel` / `logo` | `string` / `string` / `ReactNode` | Heading override, the account the code is for (an email is masked), and an optional logo. |
1566
+
1567
+ Enter in a code cell submits a complete code (the same guard as Verify applies); Enter in the
1568
+ backup-code field submits it.
1569
+
1570
+ Accessibility: the trust-device control is a `role="checkbox"` with `aria-checked` and its full
1571
+ sentence as the accessible name, and toggles with Space or Enter on web; the error is
1572
+ `role="alert"`; the code cells are labelled "Digit k of N"; the backup input is named "Backup
1573
+ code".
1574
+
1575
+ **Test IDs** (with the default `testID="two-factor-challenge"`):
1576
+
1577
+ | Element | testID |
1578
+ | --- | --- |
1579
+ | Screen root | `two-factor-challenge` |
1580
+ | TOTP cells | `two-factor-challenge-otp-cell-{k}` (1-based) |
1581
+ | Backup code input | `two-factor-challenge-backup-input` |
1582
+ | TOTP / backup toggle | `two-factor-challenge-backup-toggle` |
1583
+ | Trust-device checkbox | `two-factor-challenge-trust-device` |
1584
+ | Error | `two-factor-challenge-error` |
1585
+ | Verify button | `two-factor-challenge-submit` |
1586
+ | Use another method | `two-factor-challenge-cancel` |
1587
+ | Back | `two-factor-challenge-back` |
1588
+
1094
1589
  ### SignUpScreen
1095
1590
 
1096
1591
  Complete signup screen with email/password and optional social providers.
@@ -1099,22 +1594,754 @@ Complete signup screen with email/password and optional social providers.
1099
1594
  import {SignUpScreen} from "@terreno/ui";
1100
1595
  import {authClient} from "@/store/authClient";
1101
1596
 
1597
+ // The Better Auth client resolves {data, error} rather than throwing, and SignUpScreen does not
1598
+ // catch a rejected onSubmit, so put the error in state and pass it as `error`.
1599
+ const [error, setError] = useState<string | undefined>(undefined);
1600
+
1601
+ const signUpWithGoogle = async (): Promise<void> => {
1602
+ setError(undefined);
1603
+ const {error: socialError} = await authClient.signIn.social({
1604
+ provider: "google",
1605
+ callbackURL: "yourapp://auth",
1606
+ });
1607
+ if (socialError) {
1608
+ setError(socialError.message ?? "Couldn't sign up with Google.");
1609
+ }
1610
+ };
1611
+
1102
1612
  <SignUpScreen
1103
- onEmailSignUp={async ({email, password, name}) => {
1104
- await authClient.signUp.email({email, password, name});
1105
- }}
1106
- onSocialLogin={async (provider) => {
1107
- await authClient.signIn.social({provider, callbackURL: "yourapp://auth"});
1613
+ error={error}
1614
+ fields={[
1615
+ {name: "name", label: "Name", required: true},
1616
+ {name: "email", label: "Email", type: "email", required: true},
1617
+ {name: "password", label: "Password", type: "password", required: true},
1618
+ ]}
1619
+ onSubmit={async ({name = "", email = "", password = ""}) => {
1620
+ setError(undefined);
1621
+ const {error: signUpError} = await authClient.signUp.email({email, password, name});
1622
+ if (signUpError) {
1623
+ setError(signUpError.message ?? "Couldn't create your account.");
1624
+ }
1108
1625
  }}
1109
- socialProviders={["google", "github"]}
1110
- onSignIn={() => navigation.navigate("Login")}
1111
- requireName
1112
- requireTermsAcceptance
1626
+ oauthProviders={[{provider: "google", onPress: signUpWithGoogle}]}
1627
+ onLoginPress={() => router.push("/login")}
1113
1628
  />
1114
1629
  ``````
1115
1630
 
1631
+ Keyboard and autofill: the fields sit in a `FormContainer` (`{testID}-form`). The email field
1632
+ defaults to `autoComplete="username"` and password fields to `"new-password"`, so password
1633
+ managers offer a strong password and save it with the email; each input's accessible name is its
1634
+ field `label`. Enter moves to the next field and submits from the last one once Sign Up is
1635
+ enabled (required fields filled, password requirements met), through the form's
1636
+ `requestSubmit()`. A second submit while `onSubmit` is pending is ignored.
1637
+
1116
1638
  **Learn more:** [Configure Better Auth](../how-to/configure-better-auth.md)
1117
1639
 
1640
+ ## Account security
1641
+
1642
+ Presentational cards for an account security screen, and `AccountSecurityScreen`, which
1643
+ composes them (see [AccountSecurityScreen](#accountsecurityscreen)). Each card takes plain data
1644
+ and reports actions through callbacks; none of them calls an auth client. Each card's title
1645
+ is a level 2 heading and `AccountSecurityScreen`'s title is level 1, through `Heading`'s `level`
1646
+ prop: with `level` set, `Heading` renders `role="heading"` and `aria-level` (an `<h1>`–`<h6>`
1647
+ on web, the header accessibility role on native); without it, `Heading` stays plain text. The data types are built from
1648
+ Better Auth's responses, but they are not a one-to-one copy: `listAccounts()` returns `id`,
1649
+ `providerId`, `accountId` and `createdAt` per account and no email, so a connected screen renames
1650
+ `createdAt` to `linkedAt` and fills `email` from elsewhere (the session user, or a per-account
1651
+ info call) when it wants one shown. Dates may be `Date` objects (the Better Auth client parses
1652
+ them) or ISO 8601 strings; Luxon formats both.
1653
+
1654
+ **Callbacks must throw to show an error.** Each card catches a rejected callback and shows its
1655
+ message as a `role="alert"` card error. The Better Auth client does not reject on failure: its
1656
+ calls resolve `{data, error}`. Unwrap every call, as the examples below do:
1657
+
1658
+ ``````typescript
1659
+ const {error} = await authClient.unlinkAccount({accountId: account.id});
1660
+ if (error) {
1661
+ throw new Error(error.message ?? "Couldn't unlink that account.");
1662
+ }
1663
+ ``````
1664
+
1665
+ Shared types (exported from the package root):
1666
+
1667
+ | Type | Shape |
1668
+ | --- | --- |
1669
+ | `LinkedAccount` | `{id, providerId, accountId?, email?, linkedAt?}`. `id` is Better Auth's own account row id, the value `unlinkAccount({accountId})` takes; `accountId` is the id at the provider (display only). A password is an account with `providerId: "credential"` (`CREDENTIAL_PROVIDER_ID`). `linkedAt` (`string \| Date`) is Better Auth's `createdAt`. |
1670
+ | `LinkableProvider` | `{id, label?, iconName?}`. Branded ids (`google`, `github`, `apple`) get their brand name and icon; other ids show `label` (or the id) with a key icon. |
1671
+ | `AccountPasskey` | `{id, name?, authenticatorName?, createdAt?, lastUsedAt?, backedUp?}`. A subset of `@better-auth/passkey`'s `Passkey`, so `authClient.passkey.listUserPasskeys()` items pass as is. `authenticatorName` labels an unnamed passkey; a connected screen can fill it with `getAuthenticatorName(aaguid)` from `@better-auth/passkey` (ui does not depend on it). Better Auth stores no last-used time; pass `lastUsedAt` only when the app records one. |
1672
+ | `AccountSession` | `{id, token?, userAgent?, ipAddress?, location?, createdAt?, updatedAt?, expiresAt?}`. A subset of Better Auth's session, so `listSessions()` items pass as is. `location` is for a place name the app looks up; Better Auth has none. `updatedAt` moves only once per `session.updateAge` (a day by default), so the card labels it "Last refreshed", not "Last active". |
1673
+ | `TwoFactorEnrollment` | `{totpURI, backupCodes}`: the TOTP branch of Better Auth's `twoFactor.enable()` data. `enable()` resolves `{method: "otp"}` \| `{method: "totp", totpURI, backupCodes}`, so `onStartEnroll` narrows it with `"totpURI" in data` (see the example below). |
1674
+
1675
+ `hasPasswordCredential(accounts)` returns whether the user has a password.
1676
+ `parseUserAgent(userAgent)` returns `{browser?, os?, deviceType}` (`deviceType` is `desktop`,
1677
+ `mobile`, `tablet` or `unknown`) for common browsers and for the HTTP clients React Native apps
1678
+ send (`CFNetwork`/`Darwin` reads as "App" on iOS, `okhttp` as "App" on Android). Two limits
1679
+ of user agent sniffing: iPadOS Safari asks for desktop sites by default and reports itself as
1680
+ `Macintosh`, so it reads as Safari on macOS; and a macOS native app also sends
1681
+ `CFNetwork`/`Darwin`, so it reads as an iOS app.
1682
+ `describeUserAgent(userAgent)` turns that into "Chrome on macOS", whichever part is known, or
1683
+ "Unknown device". Neither adds a dependency.
1684
+
1685
+ ### LinkedAccountsCard
1686
+
1687
+ Lists every linked account (with its email, when given, and linked date) under the providers
1688
+ the app offers, with a Link row for each offered provider that has no account. Each linked
1689
+ account is its own row, so two Google accounts are each unlinkable. A linked account whose
1690
+ provider the app no longer offers still shows as a linked row. The password account is not a row (see PasswordCard), but it counts as a
1691
+ sign-in method.
1692
+
1693
+ ``````typescript
1694
+ import {LinkedAccountsCard} from "@terreno/ui";
1695
+
1696
+ <LinkedAccountsCard
1697
+ accounts={accounts} // every account, including "credential"
1698
+ providers={[{id: "google"}, {id: "github"}, {id: "mock-oidc", label: "Company SSO"}]}
1699
+ otherMethodCount={passkeys.length} // passkeys count as sign-in methods
1700
+ onLink={async (providerId) => {
1701
+ const {error} = await authClient.linkSocial({provider: providerId, callbackURL});
1702
+ if (error) {
1703
+ throw new Error(error.message ?? "Couldn't link that account.");
1704
+ }
1705
+ }}
1706
+ onUnlink={async (_providerId, account) => {
1707
+ const {error} = await authClient.unlinkAccount({accountId: account.id});
1708
+ if (error) {
1709
+ throw new Error(error.message ?? "Couldn't unlink that account.");
1710
+ }
1711
+ }}
1712
+ />
1713
+ ``````
1714
+
1715
+ | Prop | Type | Behavior |
1716
+ | --- | --- | --- |
1717
+ | `accounts` | `LinkedAccount[]` | Every linked account, including the `credential` password account. |
1718
+ | `providers` | `LinkableProvider[]` | Row order. A provider gets one row per linked account, or one Link row when it has none. |
1719
+ | `onLink` | `(providerId) => void \| Promise<void>` | Link button on unlinked rows. The row shows a spinner and every row is disabled until the promise settles. A rejection's message shows as the card error. |
1720
+ | `onUnlink` | `(providerId, account) => void \| Promise<void>` | Unlink button on linked rows. Pressing it opens a confirm `Modal`; the callback runs only after the user confirms, with that row's account. Pass `account.id` to `unlinkAccount({accountId})`. Same spinner and error handling as `onLink`. |
1721
+ | `otherMethodCount` | `number` | Sign-in methods that are not accounts, such as passkeys. Defaults to 0. Pass 0 unless the server sets `allowUnlinkingAll` (see below). |
1722
+ | `canUnlinkLast` | `boolean` | Allows unlinking the last sign-in method. Defaults to `false`. |
1723
+ | `error` | `string` | Card error with `role="alert"`; overrides a rejection message. |
1724
+ | `disabled` | `boolean` | Disables every row, for example while accounts load. |
1725
+ | `title` / `description` | `string` | Heading (default "Linked accounts") and the text under it. |
1726
+
1727
+ **Last sign-in method.** The card counts `accounts.length + otherMethodCount`. When that is 1
1728
+ and `canUnlinkLast` is false, the only linked provider's Unlink is disabled and a sentence under
1729
+ the row explains why (`{testID}-last-method-{id}`). Passing the `credential` account is what
1730
+ lets a user with a password unlink their only provider. Better Auth enforces its own version of
1731
+ the rule: `unlinkAccount` refuses a user's last account (passkeys do not count) unless
1732
+ `account.accountLinking.allowUnlinkingAll` is set. So only pass the passkey count as
1733
+ `otherMethodCount` when the server allows unlinking every account; otherwise pass 0 and the card
1734
+ matches the server.
1735
+
1736
+ Accessibility: Link and Unlink are named after their provider and, when known, the account's
1737
+ email ("Link GitHub", "Unlink Google (ada@example.com)"); a disabled Unlink has a visible
1738
+ explanation; errors are `role="alert"`.
1739
+
1740
+ **Test IDs** (with the default `testID="linked-accounts-card"`). `{id}` is the provider id for an
1741
+ unlinked row and for a provider's first linked account; a provider's later accounts add `-2`,
1742
+ `-3`, and so on (`linked-accounts-card-unlink-google-2`), in `accounts` order:
1743
+
1744
+ | Element | testID |
1745
+ | --- | --- |
1746
+ | Card root | `linked-accounts-card` |
1747
+ | Row / name / status | `linked-accounts-card-row-{id}`, `-name-{id}`, `-status-{id}` |
1748
+ | Link / Unlink buttons | `linked-accounts-card-link-{id}`, `linked-accounts-card-unlink-{id}` |
1749
+ | Last-method explanation | `linked-accounts-card-last-method-{id}` |
1750
+ | Confirm modal, confirm, cancel | `linked-accounts-card-confirm`, `-confirm-submit`, `-confirm-cancel` |
1751
+ | Error | `linked-accounts-card-error` |
1752
+
1753
+ ### PasswordCard
1754
+
1755
+ Shows "Change password" (current, new and confirm, plus an optional "Sign out of other devices"
1756
+ checkbox) when the user has a password, and "Set a password" (new and confirm) for a user who
1757
+ only signs in with a provider. It checks the minimum length and the confirmation inline before
1758
+ calling `onSubmit`.
1759
+
1760
+ ``````typescript
1761
+ import {hasPasswordCredential, PasswordCard} from "@terreno/ui";
1762
+
1763
+ <PasswordCard
1764
+ hasPassword={hasPasswordCredential(accounts)}
1765
+ onSubmit={async ({mode, currentPassword, newPassword, revokeOtherSessions}) => {
1766
+ if (mode === "change") {
1767
+ const {error} = await authClient.changePassword({
1768
+ currentPassword: currentPassword ?? "",
1769
+ newPassword,
1770
+ revokeOtherSessions,
1771
+ });
1772
+ if (error) {
1773
+ throw new Error(error.message ?? "Couldn't change your password.");
1774
+ }
1775
+ } else {
1776
+ await setPassword({newPassword}); // Better Auth's setPassword is server-only
1777
+ }
1778
+ }}
1779
+ />
1780
+ ``````
1781
+
1782
+ | Prop | Type | Behavior |
1783
+ | --- | --- | --- |
1784
+ | `hasPassword` | `boolean` | `true` shows change mode; `false` shows set mode. When it flips (after a first password is set), the form clears. |
1785
+ | `onSubmit` | `(values: PasswordCardSubmission) => Promise<void>` | Receives `{mode, currentPassword?, newPassword, revokeOtherSessions}`. `currentPassword` is present only in change mode; `revokeOtherSessions` is always `false` in set mode. Resolving clears the form and shows a success message (`role="status"`). Rejecting keeps the form and shows the error's message (`role="alert"`). |
1786
+ | `showRevokeOtherSessions` | `boolean` | Shows the sign-out checkbox in change mode. Defaults to `true`. |
1787
+ | `minLength` | `number` | Minimum new-password length. Defaults to 8, Better Auth's default. |
1788
+ | `error` | `string` | Error with `role="alert"`; overrides a rejection message. |
1789
+ | `loading` | `boolean` | Disables the form and shows a spinner on the submit button. |
1790
+ | `username` | `string` | The signed-in user's email or username. On web it goes in a hidden `autocomplete="username"` field of the card's form, so password managers update that account's saved password. `useAccountSecurity` passes the session user's email. |
1791
+
1792
+ Inline errors: "Enter your current password.", "Use at least N characters.", and "Passwords
1793
+ don't match." show under their field after a submit attempt and clear when that field changes.
1794
+ A failed attempt also focuses the first invalid field and shows a `role="alert"` summary ("Fix the
1795
+ 2 highlighted fields.", `{testID}-validation-error`) that updates as fields are fixed. Enter in
1796
+ the current or new password field moves to the next field; Enter in the confirm field submits
1797
+ through the card's `FormContainer` (`{testID}-form`), like the submit button. A second submit
1798
+ while `onSubmit` is pending is ignored.
1799
+
1800
+ Accessibility: inputs are named "Current password", "New password" and "Confirm new password";
1801
+ on web they carry `autocomplete="current-password"` / `"new-password"` so password managers fill
1802
+ and save correctly; the sign-out control is a `role="checkbox"` that toggles with Space or Enter.
1803
+
1804
+ **Test IDs** (with the default `testID="password-card"`):
1805
+
1806
+ | Element | testID |
1807
+ | --- | --- |
1808
+ | Card root / heading | `password-card`, `password-card-title` |
1809
+ | Current / new / confirm inputs | `password-card-current`, `password-card-new`, `password-card-confirm` |
1810
+ | Sign-out checkbox | `password-card-revoke-other-sessions` |
1811
+ | Submit | `password-card-submit` |
1812
+ | Validation summary | `password-card-validation-error` |
1813
+ | Error / success | `password-card-error`, `password-card-success` |
1814
+
1815
+ ### PasskeysCard
1816
+
1817
+ Lists the user's passkeys, each with its name (else `authenticatorName`, else "Unnamed passkey"), the date it
1818
+ was added, the date it was last used (when given), and a "Synced" badge when `backedUp` is true.
1819
+ Add, Rename and Remove each open a dialog. A device that can't create passkeys gets an
1820
+ explanation instead of the Add button.
1821
+
1822
+ ``````typescript
1823
+ import {PasskeysCard} from "@terreno/ui";
1824
+
1825
+ <PasskeysCard
1826
+ passkeys={passkeys} // authClient.passkey.listUserPasskeys() data
1827
+ supported={typeof window !== "undefined" && "PublicKeyCredential" in window}
1828
+ onAdd={async (name) => {
1829
+ const {error} = await authClient.passkey.addPasskey({name});
1830
+ if (error) {
1831
+ throw new Error(error.message ?? "Couldn't add a passkey.");
1832
+ }
1833
+ }}
1834
+ onRename={async (id, name) => {
1835
+ const {error} = await authClient.passkey.updatePasskey({id, name});
1836
+ if (error) {
1837
+ throw new Error(error.message ?? "Couldn't rename that passkey.");
1838
+ }
1839
+ }}
1840
+ onRemove={async (passkey) => {
1841
+ const {error} = await authClient.passkey.deletePasskey({id: passkey.id});
1842
+ if (error) {
1843
+ throw new Error(error.message ?? "Couldn't remove that passkey.");
1844
+ }
1845
+ }}
1846
+ />
1847
+ ``````
1848
+
1849
+ | Prop | Type | Behavior |
1850
+ | --- | --- | --- |
1851
+ | `passkeys` | `AccountPasskey[]` | One row per passkey, keyed by `id`. An empty list shows "You haven't added a passkey yet." |
1852
+ | `onAdd` | `(name?) => void \| Promise<void>` | Add opens a dialog with an optional name field; Continue calls `onAdd` with the trimmed name, or `undefined` when blank. The Add button shows a spinner and every row is disabled until the promise settles. A rejection (for example, the user cancelling the browser prompt) shows its message as the card error. |
1853
+ | `onRename` | `(id, name) => void \| Promise<void>` | Rename opens a dialog with the current name; Save is disabled while the name is blank or unchanged and calls `onRename` with the trimmed name. Same pending and error handling. |
1854
+ | `onRemove` | `(passkey) => void \| Promise<void>` | Remove opens a confirm `Modal`; the callback runs only after the user confirms. The row's Remove shows a spinner. |
1855
+ | `supported` | `boolean` | `false` hides Add and explains that this device or browser can't create passkeys. Existing passkeys can still be renamed and removed. Defaults to `true`. |
1856
+ | `error` | `string` | Card error with `role="alert"`; overrides a rejection message. |
1857
+ | `disabled` | `boolean` | Disables every button, for example while passkeys load. |
1858
+ | `title` / `description` | `string` | Heading (default "Passkeys") and the text under it. |
1859
+
1860
+ A button whose callback is not passed is not rendered.
1861
+
1862
+ Accessibility: Rename and Remove are named after their passkey ("Rename passkey Work laptop",
1863
+ "Remove passkey Work laptop"); dialog inputs are named "Passkey name"; errors are `role="alert"`.
1864
+
1865
+ **Test IDs** (with the default `testID="passkeys-card"`; `{id}` is the passkey id):
1866
+
1867
+ | Element | testID |
1868
+ | --- | --- |
1869
+ | Card root / heading | `passkeys-card`, `passkeys-card-title` |
1870
+ | Row / name / dates / synced badge | `passkeys-card-row-{id}`, `-name-{id}`, `-status-{id}`, `-synced-{id}` |
1871
+ | Rename / Remove buttons | `passkeys-card-rename-{id}`, `passkeys-card-remove-{id}` |
1872
+ | Add button | `passkeys-card-add` |
1873
+ | Empty state / unsupported explanation | `passkeys-card-empty`, `passkeys-card-unsupported` |
1874
+ | Add dialog, name input, Continue, Cancel | `passkeys-card-add-dialog`, `-add-dialog-name`, `-add-dialog-submit`, `-add-dialog-cancel` |
1875
+ | Rename dialog, name input, Save, Cancel | `passkeys-card-rename-dialog`, `-rename-dialog-name`, `-rename-dialog-submit`, `-rename-dialog-cancel` |
1876
+ | Remove confirm, confirm, cancel | `passkeys-card-confirm`, `-confirm-submit`, `-confirm-cancel` |
1877
+ | Error | `passkeys-card-error` |
1878
+
1879
+ ### SessionsCard
1880
+
1881
+ Lists the user's signed-in sessions. Each row shows the browser and OS parsed from the session's
1882
+ user agent ("Safari on iOS") with a desktop, phone or tablet icon, the location and IP address
1883
+ when known, "Signed in 3 days ago" from `createdAt`, and "Last refreshed 2 hours ago" from
1884
+ `updatedAt` (Luxon `toRelative`; anything under a minute old or in the future reads "just now").
1885
+ Better Auth refreshes `updatedAt` only once per `session.updateAge` (a day by default), so it is
1886
+ not shown as a live "last active" time. The current session is listed first, marked "This
1887
+ device", reads "Active now", and has no Sign out button: sign the current device out with the
1888
+ app's own sign-out action. The rest follow, most recently refreshed first. Without `now`, the card
1889
+ recomputes relative times every minute.
1890
+
1891
+ ``````typescript
1892
+ import {SessionsCard} from "@terreno/ui";
1893
+
1894
+ <SessionsCard
1895
+ sessions={sessions} // authClient.listSessions() data
1896
+ currentSessionId={session.data?.session.id}
1897
+ onRevoke={async (revoked) => {
1898
+ if (!revoked.token) {
1899
+ throw new Error("This session can't be signed out.");
1900
+ }
1901
+ const {error} = await authClient.revokeSession({token: revoked.token});
1902
+ if (error) {
1903
+ throw new Error(error.message ?? "Couldn't sign out that session.");
1904
+ }
1905
+ }}
1906
+ onRevokeOthers={async () => {
1907
+ const {error} = await authClient.revokeOtherSessions();
1908
+ if (error) {
1909
+ throw new Error(error.message ?? "Couldn't sign out your other sessions.");
1910
+ }
1911
+ }}
1912
+ />
1913
+ ``````
1914
+
1915
+ | Prop | Type | Behavior |
1916
+ | --- | --- | --- |
1917
+ | `sessions` | `AccountSession[]` | One row per session, keyed by `id`. |
1918
+ | `currentSessionId` / `currentSessionToken` | `string` | Identify the current session by `id` or by `token`; either match marks the row. |
1919
+ | `onRevoke` | `(session) => void \| Promise<void>` | Sign out on every non-current row. No confirm. The row shows a spinner and every other button is disabled until the promise settles; a rejection's message shows as the card error. |
1920
+ | `onRevokeOthers` | `() => void \| Promise<void>` | "Sign out everywhere else" opens a confirm `Modal` that names how many sessions it ends; the callback runs after the user confirms. Disabled, with "You're not signed in anywhere else.", when no other session exists. |
1921
+ | `now` | `Date` | The time relative times are measured from. When omitted, the card uses the current time and recomputes it every 60 seconds (the interval is cleared on unmount or when `now` is passed); pass it in tests and screenshots. |
1922
+ | `error` | `string` | Card error with `role="alert"`; overrides a rejection message. |
1923
+ | `disabled` | `boolean` | Disables every button, for example while sessions load. |
1924
+ | `title` / `description` | `string` | Heading (default "Where you're signed in") and the text under it. |
1925
+
1926
+ Accessibility: each Sign out button names its session and when it signed in ("Sign out Safari
1927
+ on iOS, signed in 3 days ago"), so two sessions on the same browser stay distinguishable; "This device" is visible text; errors are `role="alert"`.
1928
+
1929
+ **Test IDs** (with the default `testID="sessions-card"`; `{id}` is the session id):
1930
+
1931
+ | Element | testID |
1932
+ | --- | --- |
1933
+ | Card root / heading | `sessions-card`, `sessions-card-title` |
1934
+ | Row / device name / location and IP | `sessions-card-row-{id}`, `-name-{id}`, `-details-{id}` |
1935
+ | "Signed in" / "Active now" or "Last refreshed" | `sessions-card-signed-in-{id}`, `sessions-card-activity-{id}` |
1936
+ | "This device" badge | `sessions-card-current-{id}` |
1937
+ | Sign out (one session) | `sessions-card-revoke-{id}` |
1938
+ | Sign out everywhere else | `sessions-card-revoke-others` |
1939
+ | Confirm, confirm, cancel | `sessions-card-confirm`, `-confirm-submit`, `-confirm-cancel` |
1940
+ | No other sessions / empty | `sessions-card-no-others`, `sessions-card-empty` |
1941
+ | Error | `sessions-card-error` |
1942
+
1943
+ ### TwoFactorSettingsCard
1944
+
1945
+ Turns two-factor authentication with an authenticator app (TOTP) on and off and manages backup
1946
+ codes. While off, the card shows "Status: Off" and **Turn on**, which walks through four steps
1947
+ ("Step 1 of 4"):
1948
+
1949
+ 1. **Password.** Better Auth asks for it before `twoFactor.enable`.
1950
+ 2. **Scan.** A QR code of `totpURI` (`react-native-qrcode-svg`, drawn black on white so it scans
1951
+ in dark mode), plus the setup key (the URI's `secret`, in groups of four) with **Copy key**
1952
+ for users who can't scan. `parseTotpSecret(totpURI)` and `parseTotpDigits(totpURI)` are
1953
+ exported for connected screens.
1954
+ 3. **Verify.** An `OtpField` with one cell per digit (the URI's `digits`, else 6). A full code submits itself; **Verify** does the
1955
+ same. A rejection clears the code and shows the error. **Back** returns to the QR code.
1956
+ 4. **Backup codes.** The codes from `onStartEnroll`, with **Copy** (one code per line, through
1957
+ `expo-clipboard`) and **Download** on web (a `two-factor-backup-codes.txt` file) or **Share**
1958
+ on native (the system share sheet). **Finish** stays disabled until the user checks "I saved
1959
+ these backup codes". This step has no Cancel: two-factor is already on, and the codes are shown
1960
+ only once.
1961
+
1962
+ **Cancel** on steps 1–3 returns to the card without verifying. Better Auth keeps an unverified
1963
+ secret until the next `enable`, which replaces it; `user.twoFactorEnabled` stays false.
1964
+
1965
+ While on, the card shows "Status: On", the backup codes left (when `backupCodesRemaining` is given,
1966
+ with a warning below 3), **Get new backup codes** (password, then the new codes with the same "I
1967
+ saved these" gate) and **Turn off** (password, then a destructive confirm `Modal`). A flow in
1968
+ progress keeps showing when `enabled` changes underneath it, so refetching the session in
1969
+ `onVerify` doesn't hide the backup codes.
1970
+
1971
+ **Password and OAuth-only users.** Better Auth's `enable`, `generateBackupCodes`, `getTotpUri` and
1972
+ `disable` require the user's password unless its two-factor plugin sets `allowPasswordless`.
1973
+ Terreno's `twoFactor` config does not set it, so by default a user who signed up with a social
1974
+ provider must set a password first: pass `hasPassword={hasPasswordCredential(accounts)}` and the
1975
+ card disables **Turn on** (or, when two-factor is already on, **Get new backup codes** and
1976
+ **Turn off**) and says so. A consumer that adds `twoFactor({allowPasswordless: true})`
1977
+ through `config.plugins` passes `requirePassword={hasPassword}`: Better Auth still asks users who
1978
+ have a password for it, and skips it for the rest. Without `requirePassword` the password steps are
1979
+ skipped and the callbacks get `undefined`.
1980
+
1981
+ ``````typescript
1982
+ import {TwoFactorSettingsCard, hasPasswordCredential} from "@terreno/ui";
1983
+
1984
+ <TwoFactorSettingsCard
1985
+ enabled={Boolean(session.data?.user.twoFactorEnabled)}
1986
+ hasPassword={hasPasswordCredential(accounts)}
1987
+ onStartEnroll={async (password) => {
1988
+ const {data, error} = await authClient.twoFactor.enable({password: password ?? ""});
1989
+ // enable() resolves {method: "otp"} for the email/SMS OTP method; only TOTP has a URI.
1990
+ if (error || !data || !("totpURI" in data)) {
1991
+ throw new Error(error?.message ?? "Couldn't start two-factor setup.");
1992
+ }
1993
+ return {backupCodes: data.backupCodes, totpURI: data.totpURI};
1994
+ }}
1995
+ onVerify={async (code) => {
1996
+ const {error} = await authClient.twoFactor.verifyTotp({code});
1997
+ if (error) {
1998
+ throw new Error(error.message ?? "That code didn't work.");
1999
+ }
2000
+ }}
2001
+ onRegenerateBackupCodes={async (password) => {
2002
+ const {data, error} = await authClient.twoFactor.generateBackupCodes({password: password ?? ""});
2003
+ if (error || !data) {
2004
+ throw new Error(error?.message ?? "Couldn't create new backup codes.");
2005
+ }
2006
+ return data.backupCodes;
2007
+ }}
2008
+ onDisable={async (password) => {
2009
+ const {error} = await authClient.twoFactor.disable({password: password ?? ""});
2010
+ if (error) {
2011
+ throw new Error(error.message ?? "Couldn't turn off two-factor authentication.");
2012
+ }
2013
+ }}
2014
+ />
2015
+ ``````
2016
+
2017
+ | Prop | Type | Behavior |
2018
+ | --- | --- | --- |
2019
+ | `enabled` | `boolean` | Better Auth's `user.twoFactorEnabled`. False offers Turn on; true offers new backup codes and Turn off. |
2020
+ | `backupCodesRemaining` | `number` | Shown as "8 backup codes left" while on; below 3 adds a warning. Better Auth does not put this on the session; pass it when the app knows it. |
2021
+ | `onStartEnroll` | `(password?) => Promise<TwoFactorEnrollment>` | Called after the password step (or at once without `requirePassword`). Resolve `{totpURI, backupCodes}`, narrowed from `enable()`'s TOTP branch; a rejection stays on the password step with the error. |
2022
+ | `onVerify` | `(code) => Promise<void>` | Called with the full code. Resolve to move to the backup codes; reject to clear the code and show the error. |
2023
+ | `onRegenerateBackupCodes` | `(password?) => Promise<string[]>` | Called after the password step. Resolve the new codes to show them; hidden when not passed. |
2024
+ | `onDisable` | `(password?) => Promise<void>` | Called after the password step and the confirm. Hidden when not passed. |
2025
+ | `requirePassword` | `boolean` | Ask for the password before each action. Defaults to `true`; see above. |
2026
+ | `hasPassword` | `boolean` | With `requirePassword`, `false` disables Turn on, Get new backup codes and Turn off, and asks the user to set a password first. Defaults to `true`. |
2027
+ | `digits` | `number` | Cells in the code field: Better Auth's `totpOptions.digits`. Defaults to the `digits` parameter of the enrollment's `totpURI`, else 6. |
2028
+ | `error` | `string` | Card error with `role="alert"`; overrides a rejection message. |
2029
+ | `disabled` | `boolean` | Disables Turn on, Get new backup codes, Turn off and the password step, for example while the user loads. |
2030
+ | `title` / `description` | `string` | Heading (default "Two-factor authentication") and the text under it. |
2031
+ | `username` | `string` | The signed-in user's email or username. On web the password step's form gets a hidden `autocomplete="username"` field, so password managers fill that account's password. `useAccountSecurity` passes the session user's email. |
2032
+
2033
+ The card never logs the codes or the URI; a rejection is logged with `console.warn` as the
2034
+ callback threw it, so don't put secrets in error messages.
2035
+
2036
+ Accessibility:
2037
+
2038
+ - Each step replaces the content that had focus, so the card moves focus when the step changes.
2039
+ On web, focus goes to the step's heading (a `tabIndex={-1}` view holding "Step N of M" and the
2040
+ step's lead text), or to the field on the password and code steps. On native, where a view
2041
+ can't take focus, the card calls `AccessibilityInfo.announceForAccessibility` with the step
2042
+ ("Step 2 of 4. Scan this QR code…"), and announces the outcome when it returns to the card
2043
+ ("Two-factor authentication is on.", "Two-factor authentication is off."). Nothing moves on
2044
+ first render.
2045
+ - The QR code is an image named "QR code for your authenticator app".
2046
+ - Copy results ("Copied", "Couldn't copy") are in a polite live region (`aria-live` on web,
2047
+ `accessibilityLiveRegion` on Android); iOS VoiceOver ignores live regions, so the card also
2048
+ announces them there.
2049
+ - "I saved these backup codes" is a `role="checkbox"` control that Space toggles; the backup codes
2050
+ are a list; errors are `role="alert"`.
2051
+ - The password is dropped from the card's state as soon as `onStartEnroll` or
2052
+ `onRegenerateBackupCodes` resolves; the Turn off flow keeps it until the confirm.
2053
+
2054
+ **Test IDs** (with the default `testID="two-factor-settings-card"`):
2055
+
2056
+ | Element | testID |
2057
+ | --- | --- |
2058
+ | Card root / heading / status ("On" / "Off") | `two-factor-settings-card`, `-title`, `-status` |
2059
+ | Turn on / Get new backup codes / Turn off | `two-factor-settings-card-enable`, `-regenerate`, `-disable` |
2060
+ | Backup codes left / low warning / set-a-password note | `two-factor-settings-card-backup-codes-remaining`, `-backup-codes-low`, `-needs-password` |
2061
+ | Step heading (focus target) / step indicator / Cancel | `two-factor-settings-card-step-heading`, `-step`, `-cancel` |
2062
+ | Password prompt, field, submit | `two-factor-settings-card-password-prompt`, `-password`, `-password-submit` |
2063
+ | Password step form (web `<form>`) | `two-factor-settings-card-password-form` |
2064
+ | QR code / setup key / Copy key / Next | `two-factor-settings-card-qr`, `-secret`, `-copy-secret`, `-next` |
2065
+ | Code field (cells `-otp-cell-1`…) / Verify / Back | `two-factor-settings-card-otp`, `-verify`, `-back` |
2066
+ | Codes heading / list / code N (from 1) | `two-factor-settings-card-codes-title`, `-backup-codes`, `-backup-code-{n}` |
2067
+ | Copy / Download or Share / copy status | `two-factor-settings-card-copy-codes`, `-download-codes`, `-copy-codes-status` (`-copy-secret-status` for the key) |
2068
+ | "I saved these" / Finish | `two-factor-settings-card-saved`, `-finish` |
2069
+ | Turn-off confirm, confirm, cancel | `two-factor-settings-card-confirm`, `-confirm-submit`, `-confirm-cancel` |
2070
+ | Error | `two-factor-settings-card-error` |
2071
+
2072
+ ### AccountSecurityScreen
2073
+
2074
+ The account security cards composed into one screen. Each card's props go in as one object, and
2075
+ a card renders only when its object is given, so a feature the server has off simply leaves its
2076
+ prop out. The order is fixed:
2077
+
2078
+ 1. **Sign-in methods:** `LinkedAccountsCard`, then `PasswordCard`, grouped in
2079
+ `{testID}-sign-in-methods` when either is given.
2080
+ 2. `PasskeysCard`.
2081
+ 3. `TwoFactorSettingsCard`.
2082
+ 4. `SessionsCard`.
2083
+
2084
+ Above the cards is a heading (`title`, default "Security"), an optional `description`, and an
2085
+ optional `header` node (for example, "Signed in as ada@example.com"). `children` render after the
2086
+ cards, for an app-specific card. The cards stack in one column at every width, and the content
2087
+ stops growing at `maxWidth` (720px by default) and centers on wide screens. At 768px wide and
2088
+ below, `Card` draws edge to edge, so the screen drops its own side padding there and insets the
2089
+ heading, `header` and empty text to line up with the cards' content.
2090
+
2091
+ By default the screen wraps itself in a scrolling ui `Page` (the same `Page` LoginScreen uses),
2092
+ so it works as a route on its own on native and web. Inside your own `Page`, `ScrollView` or tab
2093
+ layout, pass `withPage={false}`: the screen then renders only its content and leaves scrolling to
2094
+ the parent, so scroll views don't nest.
2095
+
2096
+ ``````typescript
2097
+ import {AccountSecurityScreen, hasPasswordCredential} from "@terreno/ui";
2098
+
2099
+ // Two-factor is off on this server, so `twoFactor` is left out and its card is hidden.
2100
+ <AccountSecurityScreen
2101
+ isLoading={isLoading}
2102
+ error={loadError}
2103
+ onRetry={reload}
2104
+ linkedAccounts={{accounts, providers, onLink, onUnlink}}
2105
+ password={{hasPassword: hasPasswordCredential(accounts), onSubmit: onPasswordSubmit}}
2106
+ passkeys={{passkeys, onAdd, onRename, onRemove}}
2107
+ sessions={{sessions, currentSessionId, onRevoke, onRevokeOthers}}
2108
+ />
2109
+ ``````
2110
+
2111
+ | Prop | Type | Behavior |
2112
+ | --- | --- | --- |
2113
+ | `linkedAccounts` | `LinkedAccountsCardProps` | Props for `LinkedAccountsCard`. Omit to hide it. |
2114
+ | `password` | `PasswordCardProps` | Props for `PasswordCard`. Omit to hide it. |
2115
+ | `passkeys` | `PasskeysCardProps` | Props for `PasskeysCard`. Omit to hide it, for example when the server has no passkey plugin. |
2116
+ | `twoFactor` | `TwoFactorSettingsCardProps` | Props for `TwoFactorSettingsCard`. Omit to hide it. |
2117
+ | `sessions` | `SessionsCardProps` | Props for `SessionsCard`. Omit to hide it. |
2118
+ | `title` / `description` | `string` | Heading (default "Security") and the text under it. |
2119
+ | `header` | `ReactNode` | Content under the heading and above the cards. Shown in every state. |
2120
+ | `children` | `ReactNode` | Content after the cards. Hidden with the cards while loading or in the error state. |
2121
+ | `isLoading` | `boolean` | Shows a spinner and "Loading your security settings…" instead of the cards. Takes precedence over `error`, so a retry in flight shows as loading. |
2122
+ | `error` | `string` | A page-level error, such as the data failing to load, shown in a `role="alert"` card instead of the cards. A card action's error belongs in that card's own `error` prop (or a rejected callback), not here. |
2123
+ | `onRetry` | `() => void` | The error card's **Try again** button. Hidden when not passed. |
2124
+ | `withPage` | `boolean` | Wrap the screen in a scrolling `Page`. Defaults to `true`. |
2125
+ | `maxWidth` | `number` | The widest the content grows, in pixels. Defaults to 720. |
2126
+
2127
+ With no card props and no `children`, the screen says "There are no security settings to
2128
+ manage." (`{testID}-empty`).
2129
+
2130
+ Accessibility: the title is a level 1 heading and each card's title is level 2, so screen
2131
+ reader heading navigation lists Security, then each card; the loading state is a `progressbar`
2132
+ named "Loading security settings" with visible text; the page error is an alert.
2133
+
2134
+ **Test IDs** (with the default `testID="account-security-screen"`; each card keeps its own default
2135
+ testID, unless you pass `testID` in its props):
2136
+
2137
+ | Element | testID |
2138
+ | --- | --- |
2139
+ | Root / heading block / title / description | `account-security-screen`, `-heading`, `-title`, `-description` |
2140
+ | Sign-in methods group | `account-security-screen-sign-in-methods` |
2141
+ | Loading | `account-security-screen-loading` |
2142
+ | Error card / message / Try again | `account-security-screen-error`, `-error-message`, `-retry` |
2143
+ | No cards | `account-security-screen-empty` |
2144
+
2145
+ #### A connected screen
2146
+
2147
+ `@terreno/rtk` ships the connected version: `TerrenoAccountSecurityScreen` (and the
2148
+ `useAccountSecurity` hook behind it) wires every card to the Better Auth client, refetches the
2149
+ affected section after each change, and links in bearer mode through the OAuth handoff. See
2150
+ [Drop-in security screen](../how-to/configure-better-auth.md#6-drop-in-security-screen). To connect
2151
+ the presentational screen yourself instead, work along the lines below. It needs a client created
2152
+ with the passkey and two-factor client plugins (for `authClient.passkey` and
2153
+ `authClient.twoFactor`), reads which features are on from `useAuthMethods`, and unwraps every
2154
+ Better Auth call, because the client resolves `{data, error}` instead of rejecting. Better Auth's
2155
+ `setPassword` is server-only, so "Set a password" posts to `@terreno/api`'s
2156
+ `POST {basePath}/terreno/set-password`.
2157
+
2158
+ ``````typescript
2159
+ import {useAuthMethods} from "@terreno/rtk";
2160
+ import type {AccountPasskey, AccountSession, LinkedAccount} from "@terreno/ui";
2161
+ import {AccountSecurityScreen, hasPasswordCredential, Text} from "@terreno/ui";
2162
+ import {type FC, useCallback, useEffect, useState} from "react";
2163
+
2164
+ import {authClient} from "@/store/authClient";
2165
+
2166
+ /** Throws the Better Auth error, so the card that called shows its message. */
2167
+ const unwrap = <T,>(result: {data: T | null; error: {message?: string} | null}, fallback: string): T => {
2168
+ if (result.error || result.data === null) {
2169
+ throw new Error(result.error?.message ?? fallback);
2170
+ }
2171
+ return result.data;
2172
+ };
2173
+
2174
+ export const SecurityScreen: FC = () => {
2175
+ const {
2176
+ methods,
2177
+ isLoading: isMethodsLoading,
2178
+ error: methodsError,
2179
+ refetch: refetchMethods,
2180
+ } = useAuthMethods(authClient);
2181
+ const session = authClient.useSession();
2182
+ const [accounts, setAccounts] = useState<LinkedAccount[]>([]);
2183
+ const [passkeys, setPasskeys] = useState<AccountPasskey[]>([]);
2184
+ const [sessions, setSessions] = useState<AccountSession[]>([]);
2185
+ // False until the first load settles, so the cards never flash empty and a methods failure
2186
+ // (no load runs) is not stuck behind a spinner.
2187
+ const [hasLoaded, setHasLoaded] = useState(false);
2188
+ const [isLoading, setIsLoading] = useState(false);
2189
+ const [loadError, setLoadError] = useState<string | undefined>();
2190
+
2191
+ const loadAccounts = useCallback(async (): Promise<void> => {
2192
+ const data = unwrap(await authClient.listAccounts(), "Couldn't load your accounts.");
2193
+ setAccounts(data.map((account) => ({...account, linkedAt: account.createdAt})));
2194
+ }, []);
2195
+ const loadPasskeys = useCallback(async (): Promise<void> => {
2196
+ setPasskeys(unwrap(await authClient.passkey.listUserPasskeys(), "Couldn't load passkeys."));
2197
+ }, []);
2198
+ const loadSessions = useCallback(async (): Promise<void> => {
2199
+ setSessions(unwrap(await authClient.listSessions(), "Couldn't load your sessions."));
2200
+ }, []);
2201
+
2202
+ const load = useCallback(async (): Promise<void> => {
2203
+ setIsLoading(true);
2204
+ setLoadError(undefined);
2205
+ try {
2206
+ await Promise.all([loadAccounts(), methods?.passkey ? loadPasskeys() : null, loadSessions()]);
2207
+ } catch (error) {
2208
+ setLoadError((error as Error).message);
2209
+ } finally {
2210
+ setIsLoading(false);
2211
+ setHasLoaded(true);
2212
+ }
2213
+ }, [loadAccounts, loadPasskeys, loadSessions, methods?.passkey]);
2214
+
2215
+ // Load the cards' data once the server says which features are on.
2216
+ useEffect(() => {
2217
+ if (methods) {
2218
+ void load();
2219
+ }
2220
+ }, [load, methods]);
2221
+
2222
+ const hasPassword = hasPasswordCredential(accounts);
2223
+ const user = session.data?.user;
2224
+
2225
+ return (
2226
+ <AccountSecurityScreen
2227
+ error={methodsError ?? loadError}
2228
+ header={user ? <Text color="secondaryLight">Signed in as {user.email}</Text> : undefined}
2229
+ isLoading={
2230
+ isMethodsLoading || session.isPending || isLoading || (Boolean(methods) && !hasLoaded)
2231
+ }
2232
+ onRetry={methods ? load : refetchMethods}
2233
+ linkedAccounts={
2234
+ methods && methods.providers.length > 0
2235
+ ? {
2236
+ accounts,
2237
+ providers: methods.providers.map((id) => ({id})),
2238
+ // Bearer-mode web clients link through linkSocialWithHandoff instead.
2239
+ onLink: async (provider) => {
2240
+ unwrap(await authClient.linkSocial({callbackURL: "/security", provider}), "Couldn't link that account.");
2241
+ },
2242
+ onUnlink: async (_providerId, account) => {
2243
+ unwrap(await authClient.unlinkAccount({accountId: account.id}), "Couldn't unlink that account.");
2244
+ await loadAccounts();
2245
+ },
2246
+ }
2247
+ : undefined
2248
+ }
2249
+ password={
2250
+ methods?.emailPassword
2251
+ ? {
2252
+ hasPassword,
2253
+ onSubmit: async ({mode, currentPassword, newPassword, revokeOtherSessions}) => {
2254
+ if (mode === "change") {
2255
+ unwrap(
2256
+ await authClient.changePassword({currentPassword: currentPassword ?? "", newPassword, revokeOtherSessions}),
2257
+ "Couldn't change your password."
2258
+ );
2259
+ return;
2260
+ }
2261
+ const result = await authClient.$fetch("/terreno/set-password", {
2262
+ body: {newPassword},
2263
+ method: "POST",
2264
+ });
2265
+ if (result.error) {
2266
+ throw new Error(result.error.message ?? "Couldn't set your password.");
2267
+ }
2268
+ await loadAccounts();
2269
+ },
2270
+ }
2271
+ : undefined
2272
+ }
2273
+ passkeys={
2274
+ methods?.passkey
2275
+ ? {
2276
+ passkeys,
2277
+ supported: typeof window !== "undefined" && "PublicKeyCredential" in window,
2278
+ onAdd: async (name) => {
2279
+ unwrap(await authClient.passkey.addPasskey({name}), "Couldn't add a passkey.");
2280
+ await loadPasskeys();
2281
+ },
2282
+ onRename: async (id, name) => {
2283
+ unwrap(await authClient.passkey.updatePasskey({id, name}), "Couldn't rename that passkey.");
2284
+ await loadPasskeys();
2285
+ },
2286
+ onRemove: async (passkey) => {
2287
+ unwrap(await authClient.passkey.deletePasskey({id: passkey.id}), "Couldn't remove that passkey.");
2288
+ await loadPasskeys();
2289
+ },
2290
+ }
2291
+ : undefined
2292
+ }
2293
+ twoFactor={
2294
+ methods?.twoFactor
2295
+ ? {
2296
+ enabled: Boolean(user?.twoFactorEnabled),
2297
+ hasPassword,
2298
+ onStartEnroll: async (password) => {
2299
+ const data = unwrap(await authClient.twoFactor.enable({password: password ?? ""}), "Couldn't start two-factor setup.");
2300
+ if (!("totpURI" in data)) {
2301
+ throw new Error("Couldn't start two-factor setup.");
2302
+ }
2303
+ return {backupCodes: data.backupCodes, totpURI: data.totpURI};
2304
+ },
2305
+ onVerify: async (code) => {
2306
+ unwrap(await authClient.twoFactor.verifyTotp({code}), "That code didn't work.");
2307
+ await session.refetch();
2308
+ },
2309
+ onRegenerateBackupCodes: async (password) => {
2310
+ const data = unwrap(
2311
+ await authClient.twoFactor.generateBackupCodes({password: password ?? ""}),
2312
+ "Couldn't create new backup codes."
2313
+ );
2314
+ return data.backupCodes;
2315
+ },
2316
+ onDisable: async (password) => {
2317
+ unwrap(await authClient.twoFactor.disable({password: password ?? ""}), "Couldn't turn off two-factor authentication.");
2318
+ await session.refetch();
2319
+ },
2320
+ }
2321
+ : undefined
2322
+ }
2323
+ sessions={{
2324
+ currentSessionId: session.data?.session.id,
2325
+ sessions,
2326
+ onRevoke: async (revoked) => {
2327
+ unwrap(await authClient.revokeSession({token: revoked.token ?? ""}), "Couldn't sign out that session.");
2328
+ await loadSessions();
2329
+ },
2330
+ onRevokeOthers: async () => {
2331
+ unwrap(await authClient.revokeOtherSessions(), "Couldn't sign out your other sessions.");
2332
+ await loadSessions();
2333
+ },
2334
+ }}
2335
+ />
2336
+ );
2337
+ };
2338
+ ``````
2339
+
2340
+ `useAuthMethods` reports `passkey`, `twoFactor`, `emailPassword` and `providers`, so a card
2341
+ appears only for a feature the server has on. When the methods request fails, Try again calls its
2342
+ `refetch()`; when the methods loaded but the cards' data failed, Try again calls `load` again. Leave `sessions` out on apps that don't want to
2343
+ show it.
2344
+
1118
2345
  ## Testing Utilities
1119
2346
 
1120
2347
  @terreno/ui provides test helpers for writing component tests with @testing-library/react-native.
@@ -1305,7 +2532,9 @@ const county = formattedCountyCode("6075"); // "6075" (Santa Cruz County, CA)
1305
2532
 
1306
2533
  ## Media Query Helpers
1307
2534
 
1308
- Responsive design utilities for breakpoints and device detection.
2535
+ Responsive design utilities for breakpoints and device detection. The policy
2536
+ (support levels, which widths to test, how layouts should adapt) is
2537
+ [Responsive design](../explanation/responsive-design.md).
1309
2538
 
1310
2539
  ``````typescript
1311
2540
  import {
@@ -1313,6 +2542,7 @@ import {
1313
2542
  mediaQueryLargerThan,
1314
2543
  mediaQuerySmallerThan,
1315
2544
  isNarrowViewport,
2545
+ isSupportedDesktopWidth,
1316
2546
  } from "@terreno/ui";
1317
2547
 
1318
2548
  // Read the current breakpoint
@@ -1334,6 +2564,11 @@ if (mediaQuerySmallerThan("lg")) {
1334
2564
  if (isNarrowViewport()) {
1335
2565
  console.info("Narrow viewport");
1336
2566
  }
2567
+
2568
+ // Same floor for a width you already measured
2569
+ if (isSupportedDesktopWidth({width: 1280})) {
2570
+ console.info("Desktop layout");
2571
+ }
1337
2572
  ``````
1338
2573
 
1339
2574
  **Breakpoints are platform-specific.** `lg` and `xl` do not mean the same width on native and web.
@@ -1360,6 +2595,8 @@ On web, `sm` (320) and `md` (375) still classify widths below 1024 so layouts ca
1360
2595
 
1361
2596
  `isNarrowViewport()` is true below the supported desktop floor: native width < 1024 (`xl`), web width < 1024 (`lg`). `isMobileDevice()` is the same check and is deprecated in favor of `isNarrowViewport`.
1362
2597
 
2598
+ `isSupportedDesktopWidth({width})` is the same floor for a width you already have (for example from `useWindowDimensions`). It does not read `Dimensions` itself. Web `lg` and native `xl` both start at 1024pt, so a 768pt window is narrow on both surfaces.
2599
+
1363
2600
  Responsive `Box` direction props update automatically when the window resizes or a device rotates:
1364
2601
 
1365
2602
  ``````typescript
@@ -1375,12 +2612,19 @@ Responsive `Box` direction props update automatically when the window resizes or
1375
2612
  All responsive Boxes share one dimension listener; non-responsive Boxes do not subscribe.
1376
2613
  When multiple direction props match, the largest active breakpoint wins (`xl` over `lg` over `md` over `sm`).
1377
2614
 
1378
- `Box` takes an optional `accessibilityState` of `{checked?, disabled?}`. It is sent to screen
1379
- readers as `accessibilityState` and as `aria-checked` / `aria-disabled`, on both a plain and a
1380
- clickable Box. On a clickable Box, `disabled: true` also stops presses while the role and
2615
+ `Box` takes an optional `accessibilityState` of `{checked?, disabled?, selected?}`. It is sent to
2616
+ screen readers as `accessibilityState` and as `aria-checked` / `aria-disabled`, on both a plain and
2617
+ a clickable Box. On a clickable Box, `disabled: true` also stops presses while the role and
1381
2618
  label stay. A clickable Box with `accessibilityRole` `checkbox` or `switch` also toggles on
1382
2619
  Space on web, as ARIA expects.
1383
2620
 
2621
+ `selected` is sent as `aria-selected` when `accessibilityRole` is a selectable role (`tab`,
2622
+ `option`, `row`, `gridcell`, `treeitem`, `columnheader`, `rowheader`). On any other Box, such as a
2623
+ clickable list row, ARIA does not allow `aria-selected`, so `selected: true` is sent as
2624
+ `aria-current="true"` instead. On web that row does not also receive
2625
+ `accessibilityState.selected`, because react-native-web would turn it into `aria-selected`
2626
+ on the button. iOS and Android still read `accessibilityState.selected`.
2627
+
1384
2628
  ```tsx
1385
2629
  <Box
1386
2630
  accessibilityHint="Marks this item as done"