@softr/sdk 0.0.24 → 0.0.26-alpha-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/dist/action.d.ts +195 -0
- package/dist/action.js +21 -0
- package/dist/assertions.js +1 -7
- package/dist/auth/isomorphic.d.ts +1 -2
- package/dist/auth/methods/authenticator.d.ts +80 -0
- package/dist/auth/methods/authenticator.js +175 -0
- package/dist/auth/methods/code.d.ts +40 -0
- package/dist/auth/methods/code.js +74 -0
- package/dist/auth/methods/magicLink.d.ts +24 -0
- package/dist/auth/methods/magicLink.js +53 -0
- package/dist/auth/methods/password.d.ts +19 -5
- package/dist/auth/methods/password.js +36 -15
- package/dist/auth/methods/signup.d.ts +22 -14
- package/dist/auth/methods/signup.js +48 -54
- package/dist/auth/methods/social.d.ts +35 -0
- package/dist/auth/methods/social.js +58 -0
- package/dist/auth/methods/utils.d.ts +1 -1
- package/dist/auth/permissions.d.ts +7 -2
- package/dist/auth/platform.js +185 -7
- package/dist/auth/server.d.ts +15 -0
- package/dist/auth/server.js +16 -1
- package/dist/auth/session.js +35 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +23 -0
- package/dist/codegen/datasource-types.js +471 -0
- package/dist/codegen/fetch-metadata.js +46 -0
- package/dist/codegen/softr-env.js +102 -0
- package/dist/codegen/user-groups.js +11 -0
- package/dist/core/integration.js +1 -1
- package/dist/datasource/chart.d.ts +196 -0
- package/dist/datasource/chart.js +289 -0
- package/dist/datasource/datasource.d.ts +16 -0
- package/dist/datasource/datasource.js +17 -0
- package/dist/datasource/fields.d.ts +122 -0
- package/dist/datasource/fields.js +82 -0
- package/dist/datasource/filter.d.ts +30 -0
- package/dist/datasource/filter.js +146 -0
- package/dist/datasource/record.d.ts +138 -0
- package/dist/datasource/record.js +258 -0
- package/dist/index.d.ts +3 -5
- package/dist/index.js +2 -2
- package/dist/package.js +5 -0
- package/dist/routes/visibility.d.ts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +31 -8
- package/skills/softr-sdk/SKILL.md +11 -7
- package/skills/softr-sdk/auth/SKILL.md +222 -36
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +178 -0
- package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +76 -0
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +76 -56
- package/skills/softr-sdk/reading-records/SKILL.md +77 -93
- package/skills/softr-sdk/reading-records/references/field-types.md +41 -48
- package/skills/softr-sdk/reading-records/references/query-builder.md +66 -70
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/dist/core/datasource.d.ts +0 -31
- package/dist/core/datasource.js +0 -226
- package/dist/core/query.d.ts +0 -354
- package/dist/core/query.js +0 -528
- package/dist/core/utils.js +0 -14
- package/dist/types.d.ts +0 -128
|
@@ -139,4 +139,4 @@ type VisibilityHandle<R extends RouteVisibility = RouteVisibility> = {
|
|
|
139
139
|
*/
|
|
140
140
|
declare function defineVisibility<const R extends RouteVisibility>(rule: R): VisibilityHandle<R>;
|
|
141
141
|
//#endregion
|
|
142
|
-
export {
|
|
142
|
+
export { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { auth } from "./auth/server.js";
|
|
2
|
-
import { ds } from "./
|
|
2
|
+
import { ds } from "./datasource/datasource.js";
|
|
3
3
|
import { workflow } from "./core/workflow.js";
|
|
4
4
|
import { integration } from "./core/integration.js";
|
|
5
5
|
import "@tanstack/react-start/server-only";
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { auth } from "./auth/server.js";
|
|
2
|
-
import { ds } from "./
|
|
2
|
+
import { ds } from "./datasource/datasource.js";
|
|
3
3
|
import { workflow } from "./core/workflow.js";
|
|
4
4
|
import { integration } from "./core/integration.js";
|
|
5
5
|
import "@tanstack/react-start/server-only";
|
package/package.json
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26-alpha-0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
".": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"import": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./client": {
|
|
11
|
+
"types": "./dist/client.d.ts",
|
|
12
|
+
"import": "./dist/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"types": "./dist/server.d.ts",
|
|
16
|
+
"import": "./dist/server.js"
|
|
17
|
+
},
|
|
18
|
+
"./vite": {
|
|
19
|
+
"types": "./dist/vite.d.ts",
|
|
20
|
+
"import": "./dist/vite.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"softr-sdk": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"skills",
|
|
29
|
+
"!skills/_artifacts"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
10
33
|
},
|
|
11
|
-
"files": ["dist", "skills", "!skills/_artifacts"],
|
|
12
|
-
"publishConfig": { "access": "public" },
|
|
13
34
|
"scripts": {
|
|
14
35
|
"build": "tsdown",
|
|
15
36
|
"test": "node --import ./src/test-utils/setup.ts --test",
|
|
@@ -41,7 +62,9 @@
|
|
|
41
62
|
"optional": true
|
|
42
63
|
}
|
|
43
64
|
},
|
|
44
|
-
"keywords": [
|
|
65
|
+
"keywords": [
|
|
66
|
+
"tanstack-intent"
|
|
67
|
+
],
|
|
45
68
|
"intent": {
|
|
46
69
|
"version": 1,
|
|
47
70
|
"repo": "@softr/sdk",
|
|
@@ -5,13 +5,13 @@ description: >-
|
|
|
5
5
|
four entrypoints (@softr/sdk, /server, /client, /vite) and the client-server
|
|
6
6
|
boundary between them, softrAuthMiddleware, getSoftrContext,
|
|
7
7
|
initialSoftrContext, SoftrContext, and which sub-skill to load for route
|
|
8
|
-
visibility, the current user, reading records,
|
|
9
|
-
this when unsure which sub-skill applies, or when
|
|
10
|
-
sub-skill stands alone for its own task.
|
|
8
|
+
visibility, the current user, reading records, charts and metrics, writing
|
|
9
|
+
records, or auth. Load this when unsure which sub-skill applies, or when
|
|
10
|
+
touching the wiring; each sub-skill stands alone for its own task.
|
|
11
11
|
metadata:
|
|
12
12
|
type: core
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.25
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
# Softr SDK core
|
|
@@ -21,7 +21,7 @@ metadata:
|
|
|
21
21
|
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
22
22
|
> **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
|
|
23
23
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
|
|
24
|
-
> **CRITICAL**: `workflow.trigger`
|
|
24
|
+
> **CRITICAL**: `workflow.trigger` and `integration.fetch` are exploratory. They exist on the entrypoints below and they typecheck, but their endpoints are not final. Do not build a feature on either of them. Tell the user the capability is not ready and ask how they want to proceed.
|
|
25
25
|
|
|
26
26
|
Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `reading-records` plus `routes-and-visibility`.
|
|
27
27
|
|
|
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
29
29
|
|
|
30
30
|
| Import | Runs | Holds |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `@softr/sdk` | Both | `
|
|
32
|
+
| `@softr/sdk` | Both | `defineAction`, `defineVisibility`, `useCurrentUser`, `usePermissions`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
33
33
|
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
34
34
|
| `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
|
|
35
35
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
@@ -41,6 +41,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
41
41
|
| Add a route, or change who can see one | [routes-and-visibility/SKILL.md](./routes-and-visibility/SKILL.md) |
|
|
42
42
|
| Read the signed-in visitor or what they may do | [current-user/SKILL.md](./current-user/SKILL.md) |
|
|
43
43
|
| Fetch records from a datasource | [reading-records/SKILL.md](./reading-records/SKILL.md) |
|
|
44
|
+
| Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
|
|
44
45
|
| Create, update or delete a record | [data-mutations/SKILL.md](./data-mutations/SKILL.md) |
|
|
45
46
|
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
46
47
|
|
|
@@ -56,6 +57,9 @@ Showing the visitor's name, or hiding UI they cannot use?
|
|
|
56
57
|
Putting records on a page, in a table, a list or a detail view?
|
|
57
58
|
load softr-sdk/reading-records
|
|
58
59
|
|
|
60
|
+
Drawing a chart, a stat tile, or any aggregated number?
|
|
61
|
+
load softr-sdk/charts-and-metrics
|
|
62
|
+
|
|
59
63
|
Submitting a form, editing a record, or deleting one?
|
|
60
64
|
load softr-sdk/data-mutations
|
|
61
65
|
|
|
@@ -124,4 +128,4 @@ type User = {
|
|
|
124
128
|
|
|
125
129
|
## Version
|
|
126
130
|
|
|
127
|
-
Targets `@softr/sdk` v0.0.
|
|
131
|
+
Targets `@softr/sdk` v0.0.25.
|
|
@@ -1,30 +1,69 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: auth
|
|
3
3
|
description: >-
|
|
4
|
-
Sign a Softr visitor in, out
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
Sign a Softr visitor in, out or up from a server function: auth.signIn,
|
|
5
|
+
signInWithCode, signInWithGoogle, signInWithMagicLink, signUp, confirmSignUp
|
|
6
|
+
and signOut from @softr/sdk/server, the authenticator-app calls that finish a
|
|
7
|
+
sign-in (verifyAuthenticator, startAuthenticatorSetup,
|
|
8
|
+
confirmAuthenticatorSetup, skipAuthenticatorSetup) and the ones that change an
|
|
9
|
+
authenticator from a settings page (sendAuthenticatorResetCode,
|
|
10
|
+
resetAuthenticator, removeAuthenticator). Covers the Result shape they answer
|
|
11
|
+
with ({ success, data } | { success, error }) and its codes
|
|
12
|
+
invalidCredentials, tooManyAttempts, emailTaken, invalidDetails, invalidCode
|
|
13
|
+
and notAuthenticated, the two-step sign-up, and why a successful sign-in does
|
|
14
|
+
not always mean the visitor is signed in. The blank template already ships
|
|
15
|
+
working login and signup pages.
|
|
10
16
|
metadata:
|
|
11
17
|
type: sub-skill
|
|
12
18
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
19
|
+
library_version: 0.0.25
|
|
14
20
|
---
|
|
15
21
|
|
|
16
22
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
17
23
|
|
|
18
24
|
# Auth
|
|
19
25
|
|
|
20
|
-
`auth` is what changes who the visitor is.
|
|
26
|
+
`auth` is what changes who the visitor is.
|
|
27
|
+
|
|
28
|
+
Signing in:
|
|
29
|
+
|
|
30
|
+
| Call | Does |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `auth.signIn({ email, password })` | Signs a visitor in with an email and password |
|
|
33
|
+
| `auth.sendSignInCode({ email })` | Emails a one-time sign-in code |
|
|
34
|
+
| `auth.signInWithCode({ email, code })` | Signs a visitor in with that code |
|
|
35
|
+
| `auth.signInWithGoogle({ email, token, fullName? })` | Signs a visitor in with a Google id token the browser obtained, creating the account the first time |
|
|
36
|
+
| `auth.signInWithMagicLink({ token })` | Signs a visitor in with the token from a magic link they followed |
|
|
37
|
+
|
|
38
|
+
Signing up:
|
|
21
39
|
|
|
22
40
|
| Call | Does |
|
|
23
41
|
| --- | --- |
|
|
24
|
-
| `auth.signIn({ email, password })` | Signs a visitor in |
|
|
25
|
-
| `auth.signOut()` | Signs the visitor out |
|
|
26
42
|
| `auth.signUp({ email, name? })` | Emails a verification code, creates no account |
|
|
27
|
-
| `auth.confirmSignUp({ email,
|
|
43
|
+
| `auth.confirmSignUp({ email, code, name?, password? })` | Creates the account and signs the visitor in |
|
|
44
|
+
|
|
45
|
+
Signing out:
|
|
46
|
+
|
|
47
|
+
| Call | Does |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `auth.signOut()` | Ends every session that visitor has in this app, on every device |
|
|
50
|
+
|
|
51
|
+
Finishing a sign-in that wants an authenticator app:
|
|
52
|
+
|
|
53
|
+
| Call | Does |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `auth.verifyAuthenticator({ code })` or `({ backupCode })` | Answers the challenge with the code their app shows |
|
|
56
|
+
| `auth.startAuthenticatorSetup()` | Hands back a QR code for enrolling an app |
|
|
57
|
+
| `auth.confirmAuthenticatorSetup({ code })` | Enrols it on its first code, and returns the backup codes once |
|
|
58
|
+
| `auth.skipAuthenticatorSetup()` | Skips enrolment, where the app allows skipping |
|
|
59
|
+
|
|
60
|
+
Changing an authenticator from a settings page:
|
|
61
|
+
|
|
62
|
+
| Call | Does |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `auth.sendAuthenticatorResetCode()` | Emails the code the next two ask for |
|
|
65
|
+
| `auth.resetAuthenticator({ code })` | Unpairs the app, so a new one can be enrolled |
|
|
66
|
+
| `auth.removeAuthenticator({ code })` | Turns the authenticator off |
|
|
28
67
|
|
|
29
68
|
**The blank template already ships a working login page and signup page.** Read `src/routes/_auth/login.tsx` before writing anything new. Most tasks are an edit to those, not a new flow.
|
|
30
69
|
|
|
@@ -57,7 +96,7 @@ A form that posts `FormData` instead validates with a function, since a zod obje
|
|
|
57
96
|
.validator((fields: FormData) => Credentials.parse(Object.fromEntries(fields)))
|
|
58
97
|
```
|
|
59
98
|
|
|
60
|
-
|
|
99
|
+
Every call except `signOut` answers with a `Result` rather than throwing on something the visitor can fix. `signOut` returns nothing.
|
|
61
100
|
|
|
62
101
|
```ts
|
|
63
102
|
type Result<T> =
|
|
@@ -65,14 +104,42 @@ type Result<T> =
|
|
|
65
104
|
| { success: false; error: { code: AuthErrorCode; message: string } }
|
|
66
105
|
|
|
67
106
|
type AuthErrorCode =
|
|
68
|
-
| 'invalidCredentials' // signIn: wrong
|
|
69
|
-
| 'tooManyAttempts' //
|
|
70
|
-
| 'emailTaken' // signUp
|
|
71
|
-
| 'invalidDetails' // signUp: details Softr rejected
|
|
72
|
-
| 'invalidCode' //
|
|
107
|
+
| 'invalidCredentials' // signIn, signInWithGoogle, signInWithMagicLink: wrong password, or a token Softr would not take
|
|
108
|
+
| 'tooManyAttempts' // any call: rate limited
|
|
109
|
+
| 'emailTaken' // signUp, confirmSignUp: that email already has an account
|
|
110
|
+
| 'invalidDetails' // signUp, sendSignInCode: details Softr rejected
|
|
111
|
+
| 'invalidCode' // any call that takes a code: wrong or expired
|
|
112
|
+
| 'notAuthenticated' // a call that needs a signed-in visitor, made by one who is not
|
|
73
113
|
```
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
Branch on `error.code` with a `switch` and write your own wording. Never put `error.message` in front of the visitor.
|
|
116
|
+
|
|
117
|
+
`code` is typed as every code the SDK produces, not the ones a given call can answer, so a `switch` needs a `default` even when the cases above it are complete. Give it a message rather than leaving it out.
|
|
118
|
+
|
|
119
|
+
## A successful sign-in is not always a session
|
|
120
|
+
|
|
121
|
+
`signIn`, `signInWithCode`, `signInWithGoogle`, `signInWithMagicLink` and `confirmSignUp` all answer the same two ways:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
{ status: 'signedIn', user }
|
|
125
|
+
{ status: 'authenticatorRequired', step: 'verify' | 'setup' | 'setupOptional' }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`signedIn` is a session. Redirect into the app.
|
|
129
|
+
|
|
130
|
+
`authenticatorRequired` is not. The app asks for an authenticator app as well, and the visitor has no session until they produce one. Redirecting into the app here sends them to a page they cannot see, and a guarded page bounces them back to the login form with nothing on screen explaining why.
|
|
131
|
+
|
|
132
|
+
Send them on by `step`:
|
|
133
|
+
|
|
134
|
+
| `step` | Show them | Finish with |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `verify` | a code field | `auth.verifyAuthenticator({ code })`, or `({ backupCode })` |
|
|
137
|
+
| `setup` | the QR from `auth.startAuthenticatorSetup()` | `auth.confirmAuthenticatorSetup({ code })` |
|
|
138
|
+
| `setupOptional` | the same, plus a skip button | the same, or `auth.skipAuthenticatorSetup()` |
|
|
139
|
+
|
|
140
|
+
Those calls take only what the visitor typed. The sign-in they finish is the one already in progress for this browser, so nothing has to be carried through the UI. It does not stay open indefinitely, and a visitor who abandons the page signs in again from the start.
|
|
141
|
+
|
|
142
|
+
An app with no authenticator app configured never answers `authenticatorRequired`. The type still has both arms either way, so `result.data.user` does not exist until you have narrowed on `status`.
|
|
76
143
|
|
|
77
144
|
## Core Patterns
|
|
78
145
|
|
|
@@ -85,19 +152,25 @@ const signIn = createServerFn({ method: 'POST' })
|
|
|
85
152
|
const result = await auth.signIn(data)
|
|
86
153
|
|
|
87
154
|
if (!result.success) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
155
|
+
switch (result.error.code) {
|
|
156
|
+
case 'invalidCredentials':
|
|
157
|
+
return { message: 'Please ensure your email and password are correct.' }
|
|
158
|
+
case 'tooManyAttempts':
|
|
159
|
+
return { message: 'Too many attempts. Please try again in a few minutes.' }
|
|
160
|
+
default:
|
|
161
|
+
return { message: 'Could not sign you in. Please try again.' }
|
|
93
162
|
}
|
|
94
163
|
}
|
|
95
164
|
|
|
165
|
+
if (result.data.status === 'authenticatorRequired') {
|
|
166
|
+
throw redirect({ to: '/verify', search: { step: result.data.step } })
|
|
167
|
+
}
|
|
168
|
+
|
|
96
169
|
throw redirect({ to: '/' })
|
|
97
170
|
})
|
|
98
171
|
```
|
|
99
172
|
|
|
100
|
-
|
|
173
|
+
Three exits: a message for the form to render, a redirect to the second factor, a redirect into the app.
|
|
101
174
|
|
|
102
175
|
Call it from the component with `useServerFn`:
|
|
103
176
|
|
|
@@ -111,6 +184,43 @@ A server function that throws `redirect` needs the hook. Call the imported funct
|
|
|
111
184
|
|
|
112
185
|
A login page is a `NON_LOGGED_IN_USERS` route, which is what keeps an already-signed-in visitor off it. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
|
|
113
186
|
|
|
187
|
+
### Sign in with an emailed code
|
|
188
|
+
|
|
189
|
+
For an app that signs people in with a code instead of a password.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// Step 1: emails the code.
|
|
193
|
+
const sent = await auth.sendSignInCode({ email })
|
|
194
|
+
|
|
195
|
+
// Step 2: same two-armed result as signIn.
|
|
196
|
+
const result = await auth.signInWithCode({ email, code })
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Step one succeeds whether or not that address has an account, so the form cannot be used to find out who is registered. Say "check your email" either way, and never branch the UI on it.
|
|
200
|
+
|
|
201
|
+
### Sign in with Google
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const result = await auth.signInWithGoogle({
|
|
205
|
+
email,
|
|
206
|
+
token, // the id token Google Identity Services handed the page
|
|
207
|
+
fullName, // names a first-time visitor, ignored for an existing one
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Only an id token works. An access token, a token minted for another app, and an email that does not match it all fail as `invalidCredentials`.
|
|
212
|
+
|
|
213
|
+
### Sign in with a magic link
|
|
214
|
+
|
|
215
|
+
For the route the link in the email points at.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// Same two-armed result as signIn.
|
|
219
|
+
const result = await auth.signInWithMagicLink({ token })
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The token keeps working after it is used, so anyone holding the link can sign in as that visitor. Never log it, and redirect away from the URL that carried it.
|
|
223
|
+
|
|
114
224
|
### Sign out
|
|
115
225
|
|
|
116
226
|
```ts
|
|
@@ -120,7 +230,7 @@ const signOut = createServerFn({ method: 'POST' }).handler(async () => {
|
|
|
120
230
|
})
|
|
121
231
|
```
|
|
122
232
|
|
|
123
|
-
It returns nothing and has no failure the visitor can act on, so send them somewhere and let the redirect re-resolve the context.
|
|
233
|
+
It returns nothing and has no failure the visitor can act on, so send them somewhere and let the redirect re-resolve the context. It ends every session that visitor has in this app, on every device, so a "sign out of this device only" button is not something you can build on it.
|
|
124
234
|
|
|
125
235
|
### Sign up, in two steps
|
|
126
236
|
|
|
@@ -134,11 +244,15 @@ const startSignUp = createServerFn({ method: 'POST' })
|
|
|
134
244
|
.handler(async ({ data }) => {
|
|
135
245
|
const result = await auth.signUp(data)
|
|
136
246
|
if (!result.success) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
247
|
+
switch (result.error.code) {
|
|
248
|
+
case 'emailTaken':
|
|
249
|
+
return { message: 'That email already has an account.' }
|
|
250
|
+
case 'invalidDetails':
|
|
251
|
+
return { message: 'Please check the details and try again.' }
|
|
252
|
+
case 'tooManyAttempts':
|
|
253
|
+
return { message: 'Too many attempts. Please try again in a few minutes.' }
|
|
254
|
+
default:
|
|
255
|
+
return { message: 'Could not start the sign-up. Please try again.' }
|
|
142
256
|
}
|
|
143
257
|
}
|
|
144
258
|
return { message: null } // now show the code field
|
|
@@ -150,19 +264,91 @@ const finishSignUp = createServerFn({ method: 'POST' })
|
|
|
150
264
|
.handler(async ({ data }) => {
|
|
151
265
|
const result = await auth.confirmSignUp(data)
|
|
152
266
|
if (!result.success) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
267
|
+
switch (result.error.code) {
|
|
268
|
+
case 'emailTaken':
|
|
269
|
+
return { message: 'That email already has an account. Please sign in instead.' }
|
|
270
|
+
case 'invalidCode':
|
|
271
|
+
return { message: 'That code is not valid. Please try again.' }
|
|
272
|
+
case 'tooManyAttempts':
|
|
273
|
+
return { message: 'Too many attempts. Please try again in a few minutes.' }
|
|
274
|
+
default:
|
|
275
|
+
return { message: 'Could not finish the sign-up. Please try again.' }
|
|
158
276
|
}
|
|
159
277
|
}
|
|
160
278
|
|
|
279
|
+
if (result.data.status === 'authenticatorRequired') {
|
|
280
|
+
throw redirect({ to: '/verify', search: { step: result.data.step } })
|
|
281
|
+
}
|
|
282
|
+
|
|
161
283
|
throw redirect({ to: '/' })
|
|
162
284
|
})
|
|
163
285
|
```
|
|
164
286
|
|
|
165
|
-
The UI has to carry email
|
|
287
|
+
The UI has to carry email into step two, plus the name if step one collected one. `confirmSignUp` is the only call that creates the account.
|
|
288
|
+
|
|
289
|
+
`password` is what the account is created with. Leave it out only for an app that signs people in with an emailed code. An app that expects a password rejects a sign-up without one, and an app that does not expect one rejects a sign-up that sends it.
|
|
290
|
+
|
|
291
|
+
### Finishing a sign-in that needs an authenticator app
|
|
292
|
+
|
|
293
|
+
On `step: 'verify'` the visitor already has an app, and owes a code from it:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
const verify = createServerFn({ method: 'POST' })
|
|
297
|
+
.validator(z.object({ code: z.string() }))
|
|
298
|
+
.handler(async ({ data }) => {
|
|
299
|
+
const result = await auth.verifyAuthenticator(data)
|
|
300
|
+
if (!result.success) {
|
|
301
|
+
switch (result.error.code) {
|
|
302
|
+
case 'invalidCode':
|
|
303
|
+
return { message: 'That code is not valid. Please try again.' }
|
|
304
|
+
case 'tooManyAttempts':
|
|
305
|
+
return { message: 'Too many attempts. Please try again in a few minutes.' }
|
|
306
|
+
default:
|
|
307
|
+
return { message: 'Could not check that code. Please try again.' }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
throw redirect({ to: '/' })
|
|
311
|
+
})
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A visitor without their phone sends `{ backupCode }` instead of `{ code }`. Each backup code works once.
|
|
315
|
+
|
|
316
|
+
On `step: 'setup'` or `'setupOptional'` they have no app yet:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
const start = await auth.startAuthenticatorSetup()
|
|
320
|
+
if (!start.success) return { message: 'Could not start setup. Please try again.' }
|
|
321
|
+
|
|
322
|
+
start.data.otpauthUri // what an authenticator app reads
|
|
323
|
+
start.data.qrPngBase64 // the same URI as a PNG, for
|
|
324
|
+
// <img src={`data:image/png;base64,${qrPngBase64}`} />
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Neither of those enrols anybody. `confirmAuthenticatorSetup` does, once it accepts a first code:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
const done = await auth.confirmAuthenticatorSetup({ code })
|
|
331
|
+
if (done.success) {
|
|
332
|
+
done.data.backupCodes // returned once. Render them now, nothing fetches them again.
|
|
333
|
+
throw redirect({ to: '/' })
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Call `startAuthenticatorSetup` again to hand back a fresh QR code, for a visitor who lost the first one. Only the newest one enrols them.
|
|
338
|
+
|
|
339
|
+
`skipAuthenticatorSetup` finishes the sign-in without enrolling, and only on `setupOptional`. Later sign-ins stop asking.
|
|
340
|
+
|
|
341
|
+
### Changing an authenticator from a settings page
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
await auth.sendAuthenticatorResetCode() // emails the code the next two ask for
|
|
345
|
+
await auth.resetAuthenticator({ code }) // unpairs the app, so a new one can be enrolled
|
|
346
|
+
await auth.removeAuthenticator({ code }) // turns the authenticator off
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
All three need a signed-in visitor and answer `notAuthenticated` when there is not one. Both changes ask for a code from the visitor's inbox as well, because both change how that visitor signs in.
|
|
350
|
+
|
|
351
|
+
After a reset, enrol the new app with `startAuthenticatorSetup` and `confirmAuthenticatorSetup`, which is also where the new backup codes come from. After a removal, an app that requires an authenticator asks the visitor to set one up again on their next sign-in.
|
|
166
352
|
|
|
167
353
|
## References
|
|
168
354
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: charts-and-metrics
|
|
3
|
+
description: >-
|
|
4
|
+
Aggregate Softr datasource records inside a server function: ds.chart with
|
|
5
|
+
groupBy, metrics, where and orderBy for chart rows, ds.metric for a single
|
|
6
|
+
headline number, date bucket intervals, which aggregations each kind of field
|
|
7
|
+
offers, the two-group-column ceiling, and distinct values for filter
|
|
8
|
+
controls. Aggregation only. Listing and fetching records is reading-records.
|
|
9
|
+
metadata:
|
|
10
|
+
type: sub-skill
|
|
11
|
+
library: softr-sdk
|
|
12
|
+
library_version: 0.0.22
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
This skill builds on softr-sdk, and on reading-records for declaring the action a chart aggregates over.
|
|
16
|
+
|
|
17
|
+
# Charts and metrics
|
|
18
|
+
|
|
19
|
+
`ds.chart` groups records and aggregates each group into one row: the data behind a bar chart, a line, a pie. `ds.metric` aggregates everything into one value: the number on a stat tile. Both live on `@softr/sdk/server` and run inside a `createServerFn` handler, and both take the same read action `ds.list` takes, with the same `where` filters.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
25
|
+
import { defineAction } from '@softr/sdk'
|
|
26
|
+
import { ds } from '@softr/sdk/server'
|
|
27
|
+
|
|
28
|
+
const sales = defineAction({
|
|
29
|
+
id: 'sales',
|
|
30
|
+
datasource: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
|
|
31
|
+
operation: 'read',
|
|
32
|
+
fields: {
|
|
33
|
+
amount: 'Vk9cXm2pSz6Ld4', // CURRENCY
|
|
34
|
+
region: 'fZk3Qm9pLw2Xa1', // SELECT
|
|
35
|
+
status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'paid' | 'open' | 'void'
|
|
36
|
+
closedAt: 'Xp2dLh6vKz9Fa3', // DATETIME
|
|
37
|
+
rep: 'Gq5wNt8rJy3Hb1', // LINKED_RECORD
|
|
38
|
+
},
|
|
39
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A chart only sees the action's declared fields, so a field to group, aggregate or filter by must be in `fields`.
|
|
44
|
+
|
|
45
|
+
## Core Patterns
|
|
46
|
+
|
|
47
|
+
### A bar chart
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const ordersByRegion = createServerFn().handler(async () => {
|
|
51
|
+
const { data } = await ds
|
|
52
|
+
.chart(sales)
|
|
53
|
+
.groupBy((q) => ({ region: q.region }))
|
|
54
|
+
.metrics((q) => ({ orders: q.$count }))
|
|
55
|
+
.orderBy((q) => q.orders.desc)
|
|
56
|
+
return data
|
|
57
|
+
})
|
|
58
|
+
// data: Array<{ region: string | null; orders: number }>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
One row per group, keyed by the names you chose in `groupBy` and `metrics`. `orders` is `number`, not `number | null`: a group that exists holds at least one record. The `null` region is the bucket of records with no value. The result also carries `executionTimestamp: Date`, when the numbers were computed.
|
|
62
|
+
|
|
63
|
+
A SELECT or LINKED_RECORD group column comes back as the choice's display label, not its id. Labels are user-editable and a `where` on the same field compares ids, so a clicked bar's value cannot be fed straight back into a filter.
|
|
64
|
+
|
|
65
|
+
### A time series
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const { data } = await ds
|
|
69
|
+
.chart(sales)
|
|
70
|
+
.groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
71
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
72
|
+
.where((q) => q.status.is('paid'))
|
|
73
|
+
.orderBy((q) => q.month.asc)
|
|
74
|
+
// data: Array<{ month: string | null; revenue: number | null }>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`q.closedAt` bare groups by the field's value. The bucket leaves hanging off it group a date field by the bucket: `byDay`, `byWeek`, `byMonth`, `byQuarter`, `byYear`, and the cyclic `byHourOfDay`, `byDayOfWeek`, `byMonthOfYear`, `byQuarterOfYear`. What each one holds is in [references/aggregations.md](./references/aggregations.md); `byMonth` reads as `"2025-06"`.
|
|
78
|
+
|
|
79
|
+
**Sort the time axis ascending yourself.** Left unsorted, rows come back by the first bucket ascending when there is one, else by the first metric descending. A time axis that misses the fallback arrives in value order, which draws a scrambled line rather than failing.
|
|
80
|
+
|
|
81
|
+
### Spread, not just totals
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const { data } = await ds
|
|
85
|
+
.chart(sales)
|
|
86
|
+
.groupBy((q) => ({ region: q.region }))
|
|
87
|
+
.metrics((q) => ({
|
|
88
|
+
smallest: q.amount.min,
|
|
89
|
+
typical: q.amount.avg,
|
|
90
|
+
biggest: q.amount.max,
|
|
91
|
+
}))
|
|
92
|
+
.orderBy((q) => q.typical.desc)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Any number of metrics in one request, several over the same field included. Each row carries all of them, so one request can feed two panels. A metric key may not reuse a group key, and two metrics may not ask for the same value. `avg` comes back rounded to two decimals.
|
|
96
|
+
|
|
97
|
+
### Two group columns, which is the ceiling
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const { data } = await ds
|
|
101
|
+
.chart(sales)
|
|
102
|
+
.groupBy((q) => ({ month: q.closedAt.byMonth, region: q.region }))
|
|
103
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
104
|
+
.orderBy((q) => [q.month.asc, q.revenue.desc])
|
|
105
|
+
// data: Array<{ month: string | null; region: string | null; revenue: number | null }>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The result is long format, one row per month and region pair, so the app pivots into one array per series before charting. **A chart groups by at most two columns**, each a different field, and a field cannot be grouped and aggregated in the same chart. A third group column is a compile error.
|
|
109
|
+
|
|
110
|
+
### Distinct values, for a filter control
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const { data } = await ds.chart(sales).groupBy((q) => ({ region: q.region }))
|
|
114
|
+
// data: Array<{ region: string | null }>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Skip `metrics` entirely and the rows hold the distinct group values, one each. This is the cheapest way to fill a dropdown from a real column.
|
|
118
|
+
|
|
119
|
+
### A headline number
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const { value } = await ds
|
|
123
|
+
.metric(sales, (q) => q.amount.sum)
|
|
124
|
+
.where((q) => q.status.is('paid'))
|
|
125
|
+
// value: number | null
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
One aggregation, no grouping, one value. Whenever the answer is a single number, a stat tile or a count, reach for `ds.metric` rather than a one-row chart: there is nothing to group by and nothing to unpack out of `data`. The same measure handles apply, `q.$count` included:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const { value: open } = await ds.metric(sales, (q) => q.$count).where((q) => q.status.is('open'))
|
|
132
|
+
// open: number
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Top N
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const { data } = await ds
|
|
139
|
+
.chart(sales)
|
|
140
|
+
.groupBy((q) => ({ rep: q.rep }))
|
|
141
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
142
|
+
.orderBy((q) => q.revenue.desc)
|
|
143
|
+
|
|
144
|
+
const top5 = data.slice(0, 5)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A chart has no row limit to set, so a top-N is a client-side slice of the fully sorted rows.
|
|
148
|
+
|
|
149
|
+
### A dashboard load
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const loadDashboard = createServerFn().handler(async () => {
|
|
153
|
+
const [mtd, series] = await Promise.all([
|
|
154
|
+
ds
|
|
155
|
+
.metric(sales, (q) => q.amount.sum)
|
|
156
|
+
.where((q) => q.$and(q.status.is('paid'), q.closedAt.gte('2026-09-01'))),
|
|
157
|
+
ds
|
|
158
|
+
.chart(sales)
|
|
159
|
+
.groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
160
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
161
|
+
.where((q) => q.status.is('paid'))
|
|
162
|
+
.orderBy((q) => q.month.asc),
|
|
163
|
+
])
|
|
164
|
+
return { mtd: mtd.value, series: series.data }
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Each tile and panel is its own request, fired in parallel from one handler.
|
|
169
|
+
|
|
170
|
+
## Order of calls
|
|
171
|
+
|
|
172
|
+
`groupBy` comes first and exactly once; a chart cannot be awaited before it. `metrics` follows, at most once, or not at all for distinct values. `where` can be chained anywhere, repeatedly, and calling it more than once ands the filters together. `orderBy` sorts by the chart's own keys, group and metric alike, and only sees the keys declared so far, so call it last.
|
|
173
|
+
|
|
174
|
+
## References
|
|
175
|
+
|
|
176
|
+
- [references/aggregations.md](./references/aggregations.md): every bucket interval and its value type, which aggregations each kind of field offers, and the chart rules
|
|
177
|
+
- [reading-records/SKILL.md](../reading-records/SKILL.md): declaring the action, and the filter operators `where` accepts
|
|
178
|
+
- [reading-records/references/query-builder.md](../reading-records/references/query-builder.md): the full filter matrix
|