@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.
- package/CHANGELOG.md +393 -0
- package/README.md +32 -269
- package/dist/bootstrap.d.ts +12 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +77 -122
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
- package/dist/docs/resources/overview.md +4 -2
- package/dist/docs/resources/patterns.md +237 -176
- package/dist/docs/resources/ui.md +48 -17
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/dist/docs/upgrades/57.20.0.md +238 -0
- package/dist/docs/versioned/next/approval-rubric.md +30 -12
- package/dist/docs/versioned/next/explanation/README.md +5 -0
- package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/dist/docs/versioned/next/explanation/authentication.md +45 -1
- package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/dist/docs/versioned/next/explanation/organizations.md +37 -0
- package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/dist/docs/versioned/next/how-to/README.md +7 -2
- package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
- package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/dist/docs/versioned/next/how-to/circleci.md +15 -5
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
- package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/dist/docs/versioned/next/reference/README.md +1 -0
- package/dist/docs/versioned/next/reference/account-security.md +700 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
- package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/dist/docs/versioned/next/reference/api.md +461 -35
- package/dist/docs/versioned/next/reference/cli.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +8 -0
- package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
- package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/dist/docs/versioned/next/reference/syncdb.md +8 -1
- package/dist/docs/versioned/next/reference/ui.md +1271 -27
- package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/dist/docs/versioned/next/tutorials/README.md +4 -0
- package/dist/httpApp.d.ts +14 -0
- package/dist/httpApp.d.ts.map +1 -0
- package/dist/httpApp.js +99 -0
- package/dist/httpApp.js.map +1 -0
- package/dist/index.js +8 -90
- package/dist/index.js.map +1 -1
- package/dist/local/localTools.d.ts.map +1 -1
- package/dist/local/localTools.js +44 -14
- package/dist/local/localTools.js.map +1 -1
- package/dist/local/metro/metroDevSession.d.ts +18 -0
- package/dist/local/metro/metroDevSession.d.ts.map +1 -1
- package/dist/local/metro/metroDevSession.js +42 -3
- package/dist/local/metro/metroDevSession.js.map +1 -1
- package/dist/local/tools/databaseQuery.d.ts.map +1 -1
- package/dist/local/tools/databaseQuery.js +3 -13
- package/dist/local/tools/databaseQuery.js.map +1 -1
- package/dist/local/tools/databaseSchema.d.ts.map +1 -1
- package/dist/local/tools/databaseSchema.js +3 -13
- package/dist/local/tools/databaseSchema.js.map +1 -1
- package/dist/local/tools/localMongo.d.ts +10 -0
- package/dist/local/tools/localMongo.d.ts.map +1 -0
- package/dist/local/tools/localMongo.js +30 -0
- package/dist/local/tools/localMongo.js.map +1 -0
- package/dist/local/tools/runtime.d.ts +18 -2
- package/dist/local/tools/runtime.d.ts.map +1 -1
- package/dist/local/tools/runtime.js +75 -20
- package/dist/local/tools/runtime.js.map +1 -1
- package/dist/local/tools/syncDb.d.ts +11 -0
- package/dist/local/tools/syncDb.d.ts.map +1 -1
- package/dist/local/tools/syncDb.js +15 -3
- package/dist/local/tools/syncDb.js.map +1 -1
- package/dist/rateLimit.d.ts +35 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +112 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +42 -60
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/src/docs/guidelines/ui/guidelines/core.md +1 -1
- package/src/docs/resources/overview.md +4 -2
- package/src/docs/resources/patterns.md +237 -176
- package/src/docs/resources/ui.md +48 -17
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/src/docs/upgrades/57.20.0.md +238 -0
- package/src/docs/versioned/next/approval-rubric.md +30 -12
- package/src/docs/versioned/next/explanation/README.md +5 -0
- package/src/docs/versioned/next/explanation/account-linking.md +169 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/src/docs/versioned/next/explanation/authentication.md +45 -1
- package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/src/docs/versioned/next/explanation/organizations.md +37 -0
- package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/src/docs/versioned/next/how-to/README.md +7 -2
- package/src/docs/versioned/next/how-to/audit-log.md +22 -3
- package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/src/docs/versioned/next/how-to/circleci.md +15 -5
- package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/src/docs/versioned/next/how-to/password-reset.md +1 -1
- package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/src/docs/versioned/next/reference/README.md +1 -0
- package/src/docs/versioned/next/reference/account-security.md +700 -0
- package/src/docs/versioned/next/reference/admin-backend.md +3 -1
- package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/src/docs/versioned/next/reference/api.md +461 -35
- package/src/docs/versioned/next/reference/cli.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +8 -0
- package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/src/docs/versioned/next/reference/environment-variables.md +9 -3
- package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/src/docs/versioned/next/reference/syncdb.md +8 -1
- package/src/docs/versioned/next/reference/ui.md +1271 -27
- package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
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
|
|
1379
|
-
readers as `accessibilityState` and as `aria-checked` / `aria-disabled`, on both a plain and
|
|
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"
|