@softr/sdk 0.0.24 → 0.0.25

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.
@@ -1,30 +1,69 @@
1
1
  ---
2
2
  name: auth
3
3
  description: >-
4
- Sign a Softr visitor in, out, or up from a server function: auth.signIn,
5
- auth.signOut, auth.signUp and auth.confirmSignUp from @softr/sdk/server, the
6
- Result shape they answer with ({ success, data } | { success, error }) and its
7
- codes invalidCredentials, tooManyAttempts, emailTaken, invalidDetails and
8
- invalidCode, the two-step sign-up, and redirecting after a successful sign-in.
9
- The blank template already ships working login and signup pages.
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.24
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. It exposes four calls and nothing else:
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, password, code, name? })` | Creates the account and signs in |
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
- `signIn`, `signUp` and `confirmSignUp` answer with a `Result` rather than throwing on something the visitor can fix. `signOut` returns nothing.
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 email or password
69
- | 'tooManyAttempts' // signIn: rate limited
70
- | 'emailTaken' // signUp or confirmSignUp: that email already has an account
71
- | 'invalidDetails' // signUp: details Softr rejected
72
- | 'invalidCode' // confirmSignUp: wrong or expired verification code
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
- Each code comes from the call named beside it. Email and password is the sign-in method these calls cover.
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
- return {
89
- message:
90
- result.error.code === 'tooManyAttempts'
91
- ? 'Too many attempts. Please try again in a few minutes.'
92
- : 'Please ensure your email and password are correct.',
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
- Two exits: return a message for the form to render, or `throw redirect` on success. Do not put `error.message` in front of the visitor. Write your own wording, as above.
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
- return {
138
- message:
139
- result.error.code === 'emailTaken'
140
- ? 'That email already has an account.'
141
- : 'Please check the details and try again.',
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
- return {
154
- message:
155
- result.error.code === 'emailTaken'
156
- ? 'That email already has an account. Please sign in instead.'
157
- : 'That code is not valid. Please try again.',
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 and password into step two, plus the name if step one collected one. `name` is optional to `confirmSignUp`, the rest is not. It is the only call that creates the account.
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
 
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.24
13
+ library_version: 0.0.25
14
14
  ---
15
15
 
16
16
  **Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.24
11
+ library_version: 0.0.25
12
12
  ---
13
13
 
14
14
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. 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.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.24
13
+ library_version: 0.0.25
14
14
  ---
15
15
 
16
16
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. 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.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.24
15
+ library_version: 0.0.25
16
16
  ---
17
17
 
18
18
  **Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.