@softr/sdk 0.0.23 → 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.
- package/dist/assertions.js +1 -7
- 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/platform.js +185 -7
- package/dist/auth/server.d.ts +15 -0
- package/dist/auth/server.js +16 -1
- package/dist/auth/session.js +39 -20
- package/dist/core/datasource.d.ts +3 -3
- package/dist/core/datasource.js +17 -17
- package/dist/core/integration.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +3 -3
- package/dist/vite.d.ts +2 -9
- package/dist/vite.js +3 -68
- package/package.json +1 -6
- package/skills/softr-sdk/SKILL.md +3 -3
- package/skills/softr-sdk/auth/SKILL.md +222 -36
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +2 -2
- package/skills/softr-sdk/reading-records/SKILL.md +3 -3
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +5 -5
- package/dist/preview-identity/preview-identity.js +0 -121
- package/dist/routes/discover.d.ts +0 -14
- package/dist/routes/discover.js +0 -305
- package/dist/routes/manifest.d.ts +0 -24
- package/dist/routes/manifest.js +0 -168
- package/dist/routes/mem-fs.js +0 -71
package/dist/core/datasource.js
CHANGED
|
@@ -120,9 +120,10 @@ async function deleteRecord(datasourceId, id) {
|
|
|
120
120
|
}
|
|
121
121
|
async function getMetric(datasourceId, options) {
|
|
122
122
|
const { select, where, aggregation, field } = options;
|
|
123
|
+
if (aggregation !== "COUNT" && !field) throw new Error(`field is required for aggregation "${aggregation}"`);
|
|
123
124
|
const fieldId = field ? select?.[field] ?? field : void 0;
|
|
124
125
|
const filterCriteria = where ? dealiasFilterCriteria(buildFilterCriteria(where), select ?? {}) : {};
|
|
125
|
-
const res = await fetch(`${dsUrl(datasourceId)}/
|
|
126
|
+
const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
|
|
126
127
|
method: "POST",
|
|
127
128
|
headers: {
|
|
128
129
|
"Content-Type": "application/json",
|
|
@@ -131,10 +132,11 @@ async function getMetric(datasourceId, options) {
|
|
|
131
132
|
},
|
|
132
133
|
body: JSON.stringify({
|
|
133
134
|
options: { userLocale: "en-US" },
|
|
134
|
-
|
|
135
|
+
groupBy: [],
|
|
136
|
+
metrics: [{
|
|
135
137
|
field: fieldId,
|
|
136
138
|
aggregation
|
|
137
|
-
},
|
|
139
|
+
}],
|
|
138
140
|
filterCriteria
|
|
139
141
|
})
|
|
140
142
|
});
|
|
@@ -142,26 +144,24 @@ async function getMetric(datasourceId, options) {
|
|
|
142
144
|
const payload = await res.json();
|
|
143
145
|
const executionTimestamp = /* @__PURE__ */ new Date(payload.executionTimestamp * 1e3);
|
|
144
146
|
let value;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!fieldId) throw new Error(`field is required for aggregation "${aggregation}"`);
|
|
148
|
-
value = payload.data.find((item) => fieldId in item)?.[fieldId] ?? null;
|
|
149
|
-
}
|
|
147
|
+
const responseKey = aggregation === "COUNT" && !fieldId ? COUNT_CNT : fieldId;
|
|
148
|
+
value = payload.data.find((item) => responseKey in item)?.[responseKey] ?? null;
|
|
150
149
|
return {
|
|
151
150
|
value,
|
|
152
151
|
executionTimestamp
|
|
153
152
|
};
|
|
154
153
|
}
|
|
155
|
-
async function
|
|
154
|
+
async function getChart(datasourceId, options) {
|
|
156
155
|
const { select, where, groupBy, metrics, orderBy } = options;
|
|
157
156
|
const filterCriteria = where ? dealiasFilterCriteria(buildFilterCriteria(where), select ?? {}) : {};
|
|
158
|
-
const
|
|
157
|
+
const sortingOptions = orderBy ? dealiasOrderBy(buildOrderBy(orderBy), select ?? {}) : void 0;
|
|
159
158
|
const groupByFields = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((field) => ({ field: select?.[field] ?? field }));
|
|
160
159
|
const yAxisFields = Object.values(metrics).map(({ field, aggregation }) => ({
|
|
161
160
|
field: field ? select?.[field] ?? field : void 0,
|
|
162
161
|
aggregation
|
|
163
162
|
}));
|
|
164
|
-
|
|
163
|
+
if (!yAxisFields.length) throw new Error("chart requires at least one metric");
|
|
164
|
+
const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
|
|
165
165
|
method: "POST",
|
|
166
166
|
headers: {
|
|
167
167
|
"Content-Type": "application/json",
|
|
@@ -171,12 +171,12 @@ async function getChartData(datasourceId, options) {
|
|
|
171
171
|
body: JSON.stringify({
|
|
172
172
|
options: { userLocale: "en-US" },
|
|
173
173
|
filterCriteria,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
yAxisFields
|
|
174
|
+
sortingOptions,
|
|
175
|
+
groupBy: groupByFields,
|
|
176
|
+
metrics: yAxisFields
|
|
177
177
|
})
|
|
178
178
|
});
|
|
179
|
-
await assertResponseOk(res, `Softr datasource
|
|
179
|
+
await assertResponseOk(res, `Softr datasource chart failed for "${datasourceId}"`);
|
|
180
180
|
const payload = await res.json();
|
|
181
181
|
const executionTimestamp = /* @__PURE__ */ new Date(payload.executionTimestamp * 1e3);
|
|
182
182
|
const aliases = {};
|
|
@@ -216,11 +216,11 @@ const ds = {
|
|
|
216
216
|
update: updateRecord,
|
|
217
217
|
delete: deleteRecord,
|
|
218
218
|
metric: getMetric,
|
|
219
|
-
|
|
219
|
+
chart: getChart
|
|
220
220
|
};
|
|
221
221
|
function withoutTrailingSlash(url) {
|
|
222
222
|
return url.replace(/\/+$/, "");
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
//#endregion
|
|
226
|
-
export { ds };
|
|
226
|
+
export { ds, dsUrl };
|
package/dist/core/integration.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readSessionToken } from "../auth/session.js";
|
|
2
|
+
import { dsUrl } from "./datasource.js";
|
|
2
3
|
|
|
3
4
|
//#region src/core/integration.ts
|
|
4
|
-
const BASE_URL = () => (import.meta.env.VITE_SOFTR_BASE_URL ?? "").replace(/\/$/, "");
|
|
5
5
|
async function integrationFetch(integrationId, input, init) {
|
|
6
6
|
const req = new Request(input, init);
|
|
7
7
|
if (req.signal.aborted) throw req.signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
@@ -16,7 +16,7 @@ async function integrationFetch(integrationId, input, init) {
|
|
|
16
16
|
};
|
|
17
17
|
const method = req.method.toUpperCase();
|
|
18
18
|
if (method !== "GET" && method !== "HEAD" && req.body !== null) payload.body = await req.clone().text();
|
|
19
|
-
return fetch(`${
|
|
19
|
+
return fetch(`${dsUrl(integrationId)}/proxy`, {
|
|
20
20
|
method: "POST",
|
|
21
21
|
headers: {
|
|
22
22
|
"Content-Type": "application/json",
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { q } from "./core/query.js";
|
|
2
2
|
import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
|
|
3
|
-
import { AggregationFunction,
|
|
3
|
+
import { AggregationFunction, ChartOptions, ChartResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions } from "./types.js";
|
|
4
4
|
import { Permissions } from "./auth/permissions.js";
|
|
5
5
|
import { SoftrContext } from "./auth/context.js";
|
|
6
6
|
import { AccessBehaviour, AccessCheck, AccessError, AccessReason, RouteVisibility, VisibilityHandle, defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
|
|
7
7
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
|
|
8
8
|
import { useUserTimezone } from "./timezone.js";
|
|
9
9
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
10
|
-
export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type
|
|
10
|
+
export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type ChartOptions, type ChartResult, type CreateOptions, type DatasourcePermissions, type DatasourceTypeMap, DefaultErrorOverlay, type GetOptions, type JsonValue, type ListOptions, type ListResult, type MetricOptions, type MetricResult, type Permissions, type RecordFieldsFor, type RouteVisibility, type SoftrContext, type SoftrMe, type SoftrPermissions, type SoftrRecord, type SoftrUser, type SoftrUserGroupId, type SoftrUserGroups, type UpdateOptions, type User, type VisibilityHandle, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|
package/dist/types.d.ts
CHANGED
|
@@ -90,7 +90,7 @@ interface MetricResult {
|
|
|
90
90
|
value: number | null;
|
|
91
91
|
executionTimestamp: Date;
|
|
92
92
|
}
|
|
93
|
-
interface
|
|
93
|
+
interface ChartOptions {
|
|
94
94
|
select?: Record<string, string>;
|
|
95
95
|
where?: AnyConstructable;
|
|
96
96
|
/** Field alias (resolved via select) or raw field ID. */
|
|
@@ -103,7 +103,7 @@ interface ChartDataOptions {
|
|
|
103
103
|
/** Override the timezone for this request. */
|
|
104
104
|
timeZone?: string;
|
|
105
105
|
}
|
|
106
|
-
interface
|
|
106
|
+
interface ChartResult {
|
|
107
107
|
data: Record<string, JsonValue>[];
|
|
108
108
|
executionTimestamp: Date;
|
|
109
109
|
}
|
|
@@ -125,4 +125,4 @@ interface UpdateOptions {
|
|
|
125
125
|
data: Record<string, JsonValue>;
|
|
126
126
|
}
|
|
127
127
|
//#endregion
|
|
128
|
-
export { AggregationFunction,
|
|
128
|
+
export { AggregationFunction, ChartOptions, ChartResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions };
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
import { Manifest, ManifestNode } from "./routes/manifest.js";
|
|
2
1
|
import { PluginOption } from "vite";
|
|
3
2
|
|
|
4
3
|
//#region src/vite.d.ts
|
|
5
|
-
|
|
6
|
-
/** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
|
|
7
|
-
declare function generateManifest(root: string): Promise<string>;
|
|
8
|
-
/**
|
|
9
|
-
* Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
|
|
10
|
-
* edit engine when the pod supplies one.
|
|
11
|
-
*/
|
|
4
|
+
/** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
|
|
12
5
|
declare function softr(): PluginOption[];
|
|
13
6
|
//#endregion
|
|
14
|
-
export {
|
|
7
|
+
export { softr };
|
package/dist/vite.js
CHANGED
|
@@ -1,75 +1,10 @@
|
|
|
1
|
-
import { ManifestError, generateRoutesManifest } from "./routes/manifest.js";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
1
|
import path from "node:path";
|
|
4
2
|
import { pathToFileURL } from "node:url";
|
|
5
3
|
|
|
6
4
|
//#region src/vite.ts
|
|
7
|
-
|
|
8
|
-
async function renderManifest(root) {
|
|
9
|
-
try {
|
|
10
|
-
return await generateRoutesManifest(root);
|
|
11
|
-
} catch (error) {
|
|
12
|
-
if (error instanceof ManifestError) {
|
|
13
|
-
const { filePath, line, column, message } = error.detail;
|
|
14
|
-
throw new Error(`[softr] ${filePath}:${line}:${column} — ${message}`, { cause: error });
|
|
15
|
-
}
|
|
16
|
-
throw error;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
async function writeManifest(root, contents) {
|
|
20
|
-
const target = path.resolve(root, MANIFEST_RELATIVE_PATH);
|
|
21
|
-
await mkdir(path.dirname(target), { recursive: true });
|
|
22
|
-
await writeFile(target, contents, "utf8");
|
|
23
|
-
return target;
|
|
24
|
-
}
|
|
25
|
-
/** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
|
|
26
|
-
async function generateManifest(root) {
|
|
27
|
-
return writeManifest(root, await renderManifest(root));
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
|
|
31
|
-
* edit engine when the pod supplies one.
|
|
32
|
-
*/
|
|
5
|
+
/** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
|
|
33
6
|
function softr() {
|
|
34
|
-
return [
|
|
35
|
-
routesManifest(),
|
|
36
|
-
previewIdentity(),
|
|
37
|
-
editEngine()
|
|
38
|
-
];
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Signing the preview in writes the cookie this app's own session code reads back, so it lives here
|
|
42
|
-
* rather than in the edit engine — one implementation of the format, no version to keep in step.
|
|
43
|
-
*
|
|
44
|
-
* `SOFTR_APPLICATION_ID` gates it: the pod sets it, a developer's machine has none.
|
|
45
|
-
*/
|
|
46
|
-
async function previewIdentity() {
|
|
47
|
-
if (!process.env.SOFTR_APPLICATION_ID) return false;
|
|
48
|
-
const { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn } = await import("./preview-identity/preview-identity.js");
|
|
49
|
-
return {
|
|
50
|
-
name: "softr-preview-identity",
|
|
51
|
-
apply: "serve",
|
|
52
|
-
configureServer(server) {
|
|
53
|
-
server.middlewares.use(PREVIEW_REDEMPTION_PATH, redeemGrant);
|
|
54
|
-
server.middlewares.use(PREVIEW_SIGNED_IN_PATH, reportSignedIn);
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/** Plugin order does not matter: this puts nothing into the module graph. */
|
|
59
|
-
function routesManifest() {
|
|
60
|
-
let root = process.cwd();
|
|
61
|
-
let rendered = null;
|
|
62
|
-
return {
|
|
63
|
-
name: "softr",
|
|
64
|
-
apply: "build",
|
|
65
|
-
configResolved(config) {
|
|
66
|
-
root = config.root;
|
|
67
|
-
},
|
|
68
|
-
async closeBundle() {
|
|
69
|
-
rendered ??= renderManifest(root);
|
|
70
|
-
await writeManifest(root, await rendered);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
7
|
+
return [editEngine()];
|
|
73
8
|
}
|
|
74
9
|
/**
|
|
75
10
|
* The edit engine is provided by the pod runtime rather than an app dependency: it is never in the
|
|
@@ -91,4 +26,4 @@ async function editEngine() {
|
|
|
91
26
|
}
|
|
92
27
|
|
|
93
28
|
//#endregion
|
|
94
|
-
export {
|
|
29
|
+
export { softr };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
@@ -16,17 +16,12 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@babel/parser": "^7.28.0",
|
|
20
|
-
"@babel/traverse": "^7.28.0",
|
|
21
|
-
"@babel/types": "^7.28.0",
|
|
22
|
-
"@tanstack/router-generator": "^1.167.0",
|
|
23
19
|
"zod": "^4.4.3"
|
|
24
20
|
},
|
|
25
21
|
"devDependencies": {
|
|
26
22
|
"@tanstack/intent": "^0.3.6",
|
|
27
23
|
"@tanstack/react-router": "^1.168.0",
|
|
28
24
|
"@tanstack/react-start": "^1.168.0",
|
|
29
|
-
"@types/babel__traverse": "^7.28.0",
|
|
30
25
|
"@types/node": "^24.3.0",
|
|
31
26
|
"tsdown": "^0.15.0",
|
|
32
27
|
"typescript": "^5.9.2",
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
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**: `
|
|
24
|
+
> **CRITICAL**: `workflow.trigger` is exploratory. It exists on the entrypoints below and typechecks, but its endpoint is not final. Do not build a feature on it. 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
|
|
|
@@ -124,4 +124,4 @@ type User = {
|
|
|
124
124
|
|
|
125
125
|
## Version
|
|
126
126
|
|
|
127
|
-
Targets `@softr/sdk` v0.0.
|
|
127
|
+
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
|
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
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.
|