@segmentstream-cloud/app 0.3.2 → 0.5.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/README.md +66 -34
- package/dist/app.d.ts +1 -3
- package/dist/app.js +3 -5
- package/dist/data.d.ts +14 -23
- package/dist/data.js +7 -5
- package/dist/index.d.ts +2 -2
- package/dist/ontology.d.ts +19 -6
- package/dist/ontology.js +9 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +1 -1
- package/dist/ui/sign-in-check.d.ts +14 -0
- package/dist/ui/sign-in-check.js +41 -0
- package/dist/ui/sign-in.d.ts +1 -1
- package/dist/ui/sign-in.js +9 -8
- package/dist/ui/use-sign-in.d.ts +35 -9
- package/dist/ui/use-sign-in.js +87 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Sign-in, users, permissions, the ontology client and warehouse data access for
|
|
4
4
|
SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
|
|
5
|
-
React 19. The app's users sign in with an email code
|
|
5
|
+
React 19. The app's users sign in with an email code, or a sign-in link the first time,
|
|
6
|
+
through Neon Auth (Managed
|
|
6
7
|
Better Auth) on the workspace's Neon project; the workspace's ontology decides who may
|
|
7
8
|
use the app and what they may do. The SDK wraps `@neondatabase/auth`, so the app never
|
|
8
9
|
configures Neon Auth itself.
|
|
@@ -57,7 +58,7 @@ The route completes emailed sign-in links (`/api/auth/magic-link/verify`) itself
|
|
|
57
58
|
verifies the token through Neon Auth without a callback, sets the session cookies on
|
|
58
59
|
the app's domain and redirects to the link's `callbackURL` path. An expired, used or
|
|
59
60
|
unknown link redirects to `/sign-in?error=sign_in_link_invalid`, where `<SignIn />` (or
|
|
60
|
-
`useSignIn().linkInvalid`) offers a new
|
|
61
|
+
`useSignIn().linkInvalid`) offers a new one.
|
|
61
62
|
|
|
62
63
|
**`src/app/sign-in/page.tsx`** renders the sign-in form, and the root layout imports
|
|
63
64
|
its stylesheet once. Apps with their own design system render their own form on
|
|
@@ -125,16 +126,16 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
|
|
|
125
126
|
(no Tailwind). Every class starts with `ss-`; the `--ss-*` custom properties on
|
|
126
127
|
`:root` set colours, radius and font.
|
|
127
128
|
|
|
128
|
-
| Export | Does
|
|
129
|
-
| -------------------------------------- |
|
|
130
|
-
| `<SignIn />` | Email, then
|
|
131
|
-
| `useSignIn()` | The same sign-in without markup, for the app's own form
|
|
132
|
-
| `<SignedIn>`, `<SignedOut>` | Render children by session state
|
|
133
|
-
| `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise
|
|
134
|
-
| `<UserMenu />` | Name, email, roles and sign-out
|
|
135
|
-
| `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session
|
|
136
|
-
| `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }`
|
|
137
|
-
| `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser
|
|
129
|
+
| Export | Does |
|
|
130
|
+
| -------------------------------------- | ------------------------------------------------------------------------------- |
|
|
131
|
+
| `<SignIn />` | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
|
|
132
|
+
| `useSignIn()` | The same sign-in without markup, for the app's own form |
|
|
133
|
+
| `<SignedIn>`, `<SignedOut>` | Render children by session state |
|
|
134
|
+
| `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
|
|
135
|
+
| `<UserMenu />` | Name, email, roles and sign-out |
|
|
136
|
+
| `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
|
|
137
|
+
| `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
|
|
138
|
+
| `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
|
|
138
139
|
|
|
139
140
|
`<Can>` only hides controls: the ontology service checks every read and action. A
|
|
140
141
|
Server Component may wrap server-rendered children in `<Can>`:
|
|
@@ -148,9 +149,17 @@ Server Component may wrap server-rendered children in `<Can>`:
|
|
|
148
149
|
### Your own sign-in form
|
|
149
150
|
|
|
150
151
|
`useSignIn({ returnTo, error })` holds everything `<SignIn />` does, without markup: the
|
|
151
|
-
steps, the 30-second resend hold, the six-digit check, error sentences
|
|
152
|
-
`returnTo` after sign-in and the `not_invited` and
|
|
153
|
-
options default to the page's query parameters.
|
|
152
|
+
sign-in check, the steps, the 30-second resend hold, the six-digit check, error sentences
|
|
153
|
+
and their codes, the return to `returnTo` after sign-in and the `not_invited` and
|
|
154
|
+
`sign_in_link_invalid` states. Both options default to the page's query parameters.
|
|
155
|
+
|
|
156
|
+
`submitEmail()` first asks the ontology's `POST /api/ontology/sign-in/check` whether the
|
|
157
|
+
address may sign in. A member who has signed in with Neon Auth before gets a six-digit
|
|
158
|
+
code (`step: "code"`). A first sign-in gets a sign-in link instead (`step: "link"`),
|
|
159
|
+
because Neon Auth sends codes only to existing accounts and the link creates the
|
|
160
|
+
account. An address the ontology refuses gets `errorCode: "not_invited"`, and nothing
|
|
161
|
+
is sent. Without an ontology that answers the check (none, or one deployed with
|
|
162
|
+
`@segmentstream-cloud/ontology` before 0.4), every address gets a code. The workspace starter renders it with
|
|
154
163
|
Tailwind CSS and shadcn/ui in `src/components/sign-in-form.tsx`; `<SignIn />` stays for
|
|
155
164
|
apps without Tailwind.
|
|
156
165
|
|
|
@@ -177,11 +186,22 @@ export function SignInForm() {
|
|
|
177
186
|
onChange={(event) => signIn.setEmail(event.target.value)}
|
|
178
187
|
/>
|
|
179
188
|
{signIn.error !== null && <p role="alert">{signIn.error}</p>}
|
|
180
|
-
<button disabled={signIn.pending !== null}>
|
|
189
|
+
<button disabled={signIn.pending !== null}>Continue</button>
|
|
181
190
|
</form>
|
|
182
191
|
);
|
|
183
192
|
}
|
|
184
193
|
|
|
194
|
+
if (signIn.step === "link") {
|
|
195
|
+
return (
|
|
196
|
+
<p>
|
|
197
|
+
We sent a sign-in link to {signIn.email}. Open it to sign in.
|
|
198
|
+
<button type="button" onClick={() => void signIn.resend()} disabled={signIn.resendIn > 0}>
|
|
199
|
+
Resend link
|
|
200
|
+
</button>
|
|
201
|
+
</p>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
return (
|
|
186
206
|
<form
|
|
187
207
|
noValidate
|
|
@@ -206,19 +226,20 @@ export function SignInForm() {
|
|
|
206
226
|
}
|
|
207
227
|
```
|
|
208
228
|
|
|
209
|
-
| Field | Means
|
|
210
|
-
| -------------------------------------- |
|
|
211
|
-
| `step` | `email`, then `code` once a code is sent
|
|
212
|
-
| `email`, `setEmail`, `code`, `setCode` | The field values; `submitEmail()` trims the address
|
|
213
|
-
| `pending` | `send`, `verify`, `resend` or `sign-out` while a call runs; disable the form
|
|
214
|
-
| `error`, `notice` | A sentence for the person, and a confirmation such as a resent code
|
|
215
|
-
| `
|
|
216
|
-
| `
|
|
217
|
-
| `
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
229
|
+
| Field | Means |
|
|
230
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
231
|
+
| `step` | `email`, then `code` once a code is sent, or `link` once a sign-in link is sent |
|
|
232
|
+
| `email`, `setEmail`, `code`, `setCode` | The field values; `submitEmail()` trims the address |
|
|
233
|
+
| `pending` | `send`, `verify`, `resend` or `sign-out` while a call runs; disable the form |
|
|
234
|
+
| `error`, `notice` | A sentence for the person, and a confirmation such as a resent code |
|
|
235
|
+
| `errorCode`, `noticeCode` | Why the call failed (`not_invited`, `code_invalid`, …) and what was resent, to reword |
|
|
236
|
+
| `resendIn` | Seconds until `resend()` may be offered; 0 when it may |
|
|
237
|
+
| `submitEmail()`, `submitCode(value?)` | Check, then send a code or link; verify `value` or `code`. A wrong code clears `code` |
|
|
238
|
+
| `resend()`, `changeEmail()` | Another code, or link, to the same address; back to the email step |
|
|
239
|
+
| `signedIn`, `destination` | A session already exists: offer a link to `destination` |
|
|
240
|
+
| `notInvited`, `sessionEmail` | The person is signed in as `sessionEmail` without an invitation |
|
|
241
|
+
| `switchAccount()` | Signs out and returns to the email step |
|
|
242
|
+
| `linkInvalid` | An emailed sign-in link was expired or used: say so on the email step |
|
|
222
243
|
|
|
223
244
|
The calls take values, not form events: call `submitEmail()` and `submitCode()` from the
|
|
224
245
|
form's submit handler after `event.preventDefault()`. A second `submitCode()` while one
|
|
@@ -304,10 +325,9 @@ export default async function Page() {
|
|
|
304
325
|
- Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
|
|
305
326
|
timestamps as ISO strings, records and arrays as JSON strings.
|
|
306
327
|
|
|
307
|
-
`segmentstream.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
`app_credential_missing`.
|
|
328
|
+
`segmentstream.ontology.<entity>.aggregate(request)` uses the server-held workspace app
|
|
329
|
+
credential and supports Neon Auth apps. Stored record operations use the ontology user token.
|
|
330
|
+
`segmentstream.fetch(path)` still requires the legacy platform session.
|
|
311
331
|
|
|
312
332
|
## How requests are handled
|
|
313
333
|
|
|
@@ -366,7 +386,7 @@ Without `NEON_AUTH_BASE_URL`, 0.3 behaves exactly as 0.2, so existing apps keep
|
|
|
366
386
|
working: members of the workspace's organization sign in with their SegmentStream
|
|
367
387
|
account through `/api/segmentstream/[...path]/route.ts`
|
|
368
388
|
(`export const { GET, POST } = segmentstream.handlers();`), the proxy redirects to
|
|
369
|
-
SegmentStream, and `fetch
|
|
389
|
+
SegmentStream, and `fetch` acts as the member; warehouse queries and aggregates prefer the workspace app credential. In that mode
|
|
370
390
|
`roles` and `permissions` are empty, `can()` is `false`, `authHandlers()` answers
|
|
371
391
|
`404 neon_auth_not_configured`, and `segmentstream.ontology` sends the member's app
|
|
372
392
|
token, which the ontology service accepts while workspaces move to Neon Auth. Keep the
|
|
@@ -378,6 +398,18 @@ Add the auth route, the sign-in page and the stylesheet import; keep the legacy
|
|
|
378
398
|
`AppUser` gained `roles` and `permissions`. Under Neon Auth, people sign in again with
|
|
379
399
|
an email code, and the ontology must have invited them.
|
|
380
400
|
|
|
401
|
+
## Upgrading to 0.4
|
|
402
|
+
|
|
403
|
+
`useSignIn().step` may now be `link`: render it, or a form written for 0.3 shows the code
|
|
404
|
+
step after a link was sent. The `<SignIn />` button reads "Continue". Deploy the
|
|
405
|
+
ontology with `@segmentstream-cloud/ontology` 0.4 or later to answer the sign-in check;
|
|
406
|
+
until then every address gets a code, as in 0.3. `errorCode` and `noticeCode` let an
|
|
407
|
+
app word messages in its own language without matching the English sentences.
|
|
408
|
+
|
|
381
409
|
## License
|
|
382
410
|
|
|
383
411
|
Apache-2.0
|
|
412
|
+
|
|
413
|
+
## Upgrading to 0.5
|
|
414
|
+
|
|
415
|
+
Replace segmentstream.semantic with typed warehouse-backed entity aggregates. See [ontology aggregates](../../docs/ontology-aggregates.md). Warehouse-backed clients expose no record CRUD or actions.
|
package/dist/app.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Environment, SegmentStreamAppConfig } from "./config.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { WarehouseClient } from "./data.js";
|
|
3
3
|
import type { SegmentStreamRouteHandlers } from "./handlers.js";
|
|
4
4
|
import type { AuthRouteHandlers, CreateNeonAuth, SignOutOptions } from "./neon.js";
|
|
5
5
|
import type { OntologyClient } from "./ontology.js";
|
|
@@ -44,8 +44,6 @@ export interface SegmentStreamApp<O = unknown> {
|
|
|
44
44
|
* (`SEGMENTSTREAM_APP_CREDENTIAL`) when it is set, else the signed-in member's session.
|
|
45
45
|
*/
|
|
46
46
|
readonly warehouse: WarehouseClient;
|
|
47
|
-
/** Governed metrics from the workspace's deployed semantic models. Server-only. Legacy sign-in only. */
|
|
48
|
-
readonly semantic: SemanticClient;
|
|
49
47
|
/** The app's ontology service, `/api/ontology`, as the signed-in user. Server-only. */
|
|
50
48
|
readonly ontology: OntologyClient<O>;
|
|
51
49
|
}
|
package/dist/app.js
CHANGED
|
@@ -48,13 +48,12 @@ export function createApp(config, dependencies = {}) {
|
|
|
48
48
|
getAccessToken: legacy.getAccessToken,
|
|
49
49
|
fetch: data.fetch,
|
|
50
50
|
warehouse: data.warehouse,
|
|
51
|
-
semantic: data.semantic,
|
|
52
51
|
// The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
|
|
53
52
|
ontology: createOntologyClient(createOntologyTransport({
|
|
54
53
|
fetch: fetchImplementation,
|
|
55
54
|
ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
|
|
56
55
|
credentials: { token: legacy.getAccessToken },
|
|
57
|
-
})),
|
|
56
|
+
}), data.aggregateTransport),
|
|
58
57
|
};
|
|
59
58
|
}
|
|
60
59
|
const neon = createNeonSession({
|
|
@@ -82,8 +81,7 @@ export function createApp(config, dependencies = {}) {
|
|
|
82
81
|
getAccessToken: async () => null,
|
|
83
82
|
fetch: data.fetch,
|
|
84
83
|
warehouse: data.warehouse,
|
|
85
|
-
|
|
86
|
-
ontology: createOntologyClient(neon.ontologyTransport),
|
|
84
|
+
ontology: createOntologyClient(neon.ontologyTransport, data.aggregateTransport),
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
/** Under Neon Auth the app has no SegmentStream session to call the API with. */
|
|
@@ -91,7 +89,7 @@ async function noSegmentStreamSession() {
|
|
|
91
89
|
throw new SegmentStreamApiError({
|
|
92
90
|
code: "app_credential_missing",
|
|
93
91
|
status: 401,
|
|
94
|
-
detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
|
|
92
|
+
detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries and ontology aggregates use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
|
|
95
93
|
});
|
|
96
94
|
}
|
|
97
95
|
function notConfiguredHandlers() {
|
package/dist/data.d.ts
CHANGED
|
@@ -43,38 +43,39 @@ export interface WarehouseDryRun {
|
|
|
43
43
|
readonly bytesProcessed: string | null;
|
|
44
44
|
readonly maximumBytesBilled: string;
|
|
45
45
|
}
|
|
46
|
-
export interface
|
|
47
|
-
readonly dimensions: readonly
|
|
48
|
-
readonly measures: readonly
|
|
49
|
-
readonly dateRange
|
|
46
|
+
export interface AggregateRequest<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
|
|
47
|
+
readonly dimensions: readonly Property[];
|
|
48
|
+
readonly measures: readonly Measure[];
|
|
49
|
+
readonly dateRange?: {
|
|
50
50
|
readonly from: string;
|
|
51
51
|
readonly to: string;
|
|
52
52
|
};
|
|
53
|
+
readonly granularityOf?: Temporal;
|
|
53
54
|
readonly granularity?: "day" | "week" | "month";
|
|
54
55
|
readonly filters?: ReadonlyArray<{
|
|
55
|
-
readonly dimension:
|
|
56
|
+
readonly dimension: Property;
|
|
56
57
|
readonly operator: "equals" | "in" | "isNull" | "isNotNull";
|
|
57
58
|
readonly value?: string | number | boolean;
|
|
58
59
|
readonly values?: ReadonlyArray<string | number | boolean>;
|
|
59
60
|
}>;
|
|
60
61
|
readonly orderBy?: ReadonlyArray<{
|
|
61
|
-
readonly measure?:
|
|
62
|
-
readonly dimension?:
|
|
62
|
+
readonly measure?: Measure;
|
|
63
|
+
readonly dimension?: Property;
|
|
63
64
|
readonly direction: "asc" | "desc";
|
|
64
65
|
}>;
|
|
65
66
|
readonly limit?: number;
|
|
66
67
|
}
|
|
67
|
-
export interface
|
|
68
|
+
export interface AggregateField {
|
|
68
69
|
readonly name: string;
|
|
69
70
|
readonly label: string;
|
|
70
71
|
readonly type: string;
|
|
71
72
|
readonly unit?: string;
|
|
72
73
|
readonly format?: string;
|
|
73
74
|
}
|
|
74
|
-
export interface
|
|
75
|
+
export interface AggregateResult<Row = Record<string, string | boolean | null>> {
|
|
75
76
|
readonly deploymentId: string | null;
|
|
76
77
|
readonly definitionDigest: string;
|
|
77
|
-
readonly fields: readonly
|
|
78
|
+
readonly fields: readonly AggregateField[];
|
|
78
79
|
readonly rows: Row[];
|
|
79
80
|
readonly truncated: boolean;
|
|
80
81
|
readonly execution: {
|
|
@@ -94,18 +95,6 @@ export interface WarehouseClient {
|
|
|
94
95
|
/** Validates SQL and estimates bytes processed without running it. */
|
|
95
96
|
readonly dryRun: (sql: string, options?: Pick<WarehouseQueryOptions, "params" | "maximumBytesBilled" | "signal">) => Promise<WarehouseDryRun>;
|
|
96
97
|
}
|
|
97
|
-
export interface SemanticClient {
|
|
98
|
-
readonly list: () => Promise<{
|
|
99
|
-
readonly deploymentId: string | null;
|
|
100
|
-
readonly models: readonly unknown[];
|
|
101
|
-
}>;
|
|
102
|
-
readonly get: (model: string) => Promise<{
|
|
103
|
-
readonly deploymentId: string | null;
|
|
104
|
-
readonly definitionDigest: string;
|
|
105
|
-
readonly model: unknown;
|
|
106
|
-
}>;
|
|
107
|
-
readonly query: <Row = Record<string, string | boolean | null>>(model: string, request: SemanticQueryRequest) => Promise<SemanticQueryResult<Row>>;
|
|
108
|
-
}
|
|
109
98
|
export interface DataClientDependencies {
|
|
110
99
|
readonly config: ResolvedConfig;
|
|
111
100
|
readonly fetch: Fetch;
|
|
@@ -122,5 +111,7 @@ export interface DataClientDependencies {
|
|
|
122
111
|
export declare function createDataClient(dependencies: DataClientDependencies): {
|
|
123
112
|
fetch: (path: string, init?: RequestInit) => Promise<Response>;
|
|
124
113
|
warehouse: WarehouseClient;
|
|
125
|
-
|
|
114
|
+
aggregateTransport: {
|
|
115
|
+
fetch: (path: string, init: RequestInit) => Promise<Response>;
|
|
116
|
+
};
|
|
126
117
|
};
|
package/dist/data.js
CHANGED
|
@@ -154,12 +154,14 @@ export function createDataClient(dependencies) {
|
|
|
154
154
|
};
|
|
155
155
|
},
|
|
156
156
|
};
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
const aggregateTransport = {
|
|
158
|
+
fetch: (path, init) => {
|
|
159
|
+
if (!/^\/[a-z][a-z0-9_]*\/aggregate$/.test(path))
|
|
160
|
+
throw new TypeError("Invalid aggregate path.");
|
|
161
|
+
return authorizedFetch(`${workspace}/entities${path}`, init, warehouseToken);
|
|
162
|
+
},
|
|
161
163
|
};
|
|
162
|
-
return { fetch: apiFetch, warehouse,
|
|
164
|
+
return { fetch: apiFetch, warehouse, aggregateTransport };
|
|
163
165
|
}
|
|
164
166
|
function bytesLimit(value) {
|
|
165
167
|
if (value === undefined) {
|
package/dist/index.d.ts
CHANGED
|
@@ -22,9 +22,9 @@ export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
|
|
|
22
22
|
export type { ApiIssue } from "./errors.js";
|
|
23
23
|
export type { CurrentUser } from "./me.js";
|
|
24
24
|
export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
|
|
25
|
-
export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, OntologyClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
|
|
25
|
+
export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, OntologyClient, AggregateEntityClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
|
|
26
26
|
export type { PermissionHolder } from "./permissions.js";
|
|
27
|
-
export type {
|
|
27
|
+
export type { AggregateField, AggregateRequest, AggregateResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
|
|
28
28
|
export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
|
|
29
29
|
export type { SegmentStreamAppConfig } from "./config.js";
|
|
30
30
|
export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
|
package/dist/ontology.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AggregateRequest, AggregateResult } from "./data.js";
|
|
1
2
|
/**
|
|
2
3
|
* The typed client for the app's ontology service, `/api/ontology`. The app imports its ontology as
|
|
3
4
|
* types only (`import type ontology from "../../ontology"`) and passes `typeof ontology` to
|
|
@@ -185,18 +186,30 @@ type ResultOf<A> = A extends {
|
|
|
185
186
|
type ActionClients<A> = {
|
|
186
187
|
readonly [K in keyof A & string]: ActionCall<InputOf<A[K]>, ResultOf<A[K]>>;
|
|
187
188
|
};
|
|
189
|
+
type PropertiesOf<E> = E extends {
|
|
190
|
+
readonly properties: infer P;
|
|
191
|
+
} ? P : never;
|
|
192
|
+
type MeasuresOf<E> = E extends {
|
|
193
|
+
readonly measures: infer M;
|
|
194
|
+
} ? keyof M & string : never;
|
|
195
|
+
export interface AggregateEntityClient<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
|
|
196
|
+
readonly aggregate: (request: AggregateRequest<Property, Measure, Temporal>, options?: RequestOptions) => Promise<AggregateResult>;
|
|
197
|
+
}
|
|
198
|
+
type ClientOf<E> = E extends {
|
|
199
|
+
readonly definition: {
|
|
200
|
+
readonly source: object;
|
|
201
|
+
};
|
|
202
|
+
} ? AggregateEntityClient<keyof PropertiesOf<E> & string, MeasuresOf<E>> : EntityClient<RecordOf<E>, ActionClients<ActionsOf<E>>>;
|
|
188
203
|
type TypedOntologyClient<O> = {
|
|
189
|
-
readonly [N in NameOf<EntitiesOf<O>>]:
|
|
190
|
-
readonly name: N;
|
|
191
|
-
}>>, ActionClients<ActionsOf<Extract<EntitiesOf<O>, {
|
|
204
|
+
readonly [N in NameOf<EntitiesOf<O>>]: ClientOf<Extract<EntitiesOf<O>, {
|
|
192
205
|
readonly name: N;
|
|
193
|
-
}
|
|
206
|
+
}>>;
|
|
194
207
|
};
|
|
195
208
|
/** A client for any entity, when the app passes no ontology type. */
|
|
196
209
|
export type UntypedOntologyClient = {
|
|
197
210
|
readonly [entity: string]: EntityClient<JsonRecord, {
|
|
198
211
|
readonly [action: string]: ActionCall<JsonRecord, JsonValue>;
|
|
199
|
-
}
|
|
212
|
+
}> & AggregateEntityClient;
|
|
200
213
|
};
|
|
201
214
|
/**
|
|
202
215
|
* `segmentstream.ontology`: one client per entity of `O`, the type of the app's ontology
|
|
@@ -211,7 +224,7 @@ export interface OntologyTransport {
|
|
|
211
224
|
* Entities and actions exist only in the app's types, so the client resolves them by name when
|
|
212
225
|
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
|
|
213
226
|
*/
|
|
214
|
-
export declare function createOntologyClient<O>(transport: OntologyTransport): OntologyClient<O>;
|
|
227
|
+
export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
|
|
215
228
|
/** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
|
|
216
229
|
export declare function findParameters(query: OntologyQuery<JsonRecord>): URLSearchParams;
|
|
217
230
|
export {};
|
package/dist/ontology.js
CHANGED
|
@@ -19,7 +19,7 @@ const RESERVED = new Set([
|
|
|
19
19
|
* Entities and actions exist only in the app's types, so the client resolves them by name when
|
|
20
20
|
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
|
|
21
21
|
*/
|
|
22
|
-
export function createOntologyClient(transport) {
|
|
22
|
+
export function createOntologyClient(transport, aggregateTransport) {
|
|
23
23
|
const entities = new Map();
|
|
24
24
|
return new Proxy(Object.create(null), {
|
|
25
25
|
get(_target, property) {
|
|
@@ -28,7 +28,14 @@ export function createOntologyClient(transport) {
|
|
|
28
28
|
}
|
|
29
29
|
let client = entities.get(property);
|
|
30
30
|
if (client === undefined) {
|
|
31
|
-
client =
|
|
31
|
+
client = {
|
|
32
|
+
...entityClient(property, transport),
|
|
33
|
+
aggregate: (request, options = {}) => {
|
|
34
|
+
if (!aggregateTransport)
|
|
35
|
+
throw new TypeError("An aggregate transport is required.");
|
|
36
|
+
return send(aggregateTransport, "POST", `/${property}/aggregate`, request, options.signal);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
32
39
|
entities.set(property, client);
|
|
33
40
|
}
|
|
34
41
|
return client;
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ export { usePermissions, useSession, useUser } from "./hooks.js";
|
|
|
4
4
|
export type { Permissions } from "./hooks.js";
|
|
5
5
|
export { SignIn } from "./sign-in.js";
|
|
6
6
|
export type { SignInProps } from "./sign-in.js";
|
|
7
|
-
export { signInErrorMessage, useSignIn } from "./use-sign-in.js";
|
|
8
|
-
export type { SignInController, SignInOptions } from "./use-sign-in.js";
|
|
7
|
+
export { SIGN_IN_ERROR_MESSAGES, signInErrorCode, signInErrorMessage, useSignIn, } from "./use-sign-in.js";
|
|
8
|
+
export type { SignInController, SignInErrorCode, SignInNoticeCode, SignInOptions, } from "./use-sign-in.js";
|
|
9
9
|
export type { SessionState, SessionUser, UserState } from "./store.js";
|
|
10
10
|
export { isAllowed } from "../permissions.js";
|
|
11
11
|
export type { CurrentUser } from "../me.js";
|
package/dist/ui/index.js
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
|
|
6
6
|
export { usePermissions, useSession, useUser } from "./hooks.js";
|
|
7
7
|
export { SignIn } from "./sign-in.js";
|
|
8
|
-
export { signInErrorMessage, useSignIn } from "./use-sign-in.js";
|
|
8
|
+
export { SIGN_IN_ERROR_MESSAGES, signInErrorCode, signInErrorMessage, useSignIn, } from "./use-sign-in.js";
|
|
9
9
|
export { isAllowed } from "../permissions.js";
|
|
10
10
|
export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The ontology's sign-in check, which the deployment serves beside the app. */
|
|
2
|
+
export declare const SIGN_IN_CHECK_PATH = "/api/ontology/sign-in/check";
|
|
3
|
+
/**
|
|
4
|
+
* How an address signs in, or why it may not: `code` for a member with a Neon Auth account, `link`
|
|
5
|
+
* for a first sign-in, which the link's account creation needs; `not_invited`, `email_invalid` or
|
|
6
|
+
* `rate_limited` when the ontology refuses before any email is sent.
|
|
7
|
+
*/
|
|
8
|
+
export type SignInCheckOutcome = "code" | "link" | "not_invited" | "email_invalid" | "rate_limited";
|
|
9
|
+
/**
|
|
10
|
+
* Asks the ontology how this address signs in, before any email is sent. An app without an
|
|
11
|
+
* ontology, an ontology deployed with an SDK before 0.4, an answer the SDK cannot read and a failed
|
|
12
|
+
* request all get `code`: the sign-in works as it did before the check existed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function checkSignIn(email: string): Promise<SignInCheckOutcome>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readJson } from "../errors.js";
|
|
2
|
+
/** The ontology's sign-in check, which the deployment serves beside the app. */
|
|
3
|
+
export const SIGN_IN_CHECK_PATH = "/api/ontology/sign-in/check";
|
|
4
|
+
/**
|
|
5
|
+
* Asks the ontology how this address signs in, before any email is sent. An app without an
|
|
6
|
+
* ontology, an ontology deployed with an SDK before 0.4, an answer the SDK cannot read and a failed
|
|
7
|
+
* request all get `code`: the sign-in works as it did before the check existed.
|
|
8
|
+
*/
|
|
9
|
+
export async function checkSignIn(email) {
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await fetch(SIGN_IN_CHECK_PATH, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
15
|
+
body: JSON.stringify({ email }),
|
|
16
|
+
credentials: "same-origin",
|
|
17
|
+
cache: "no-store",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "code";
|
|
22
|
+
}
|
|
23
|
+
if (response.status === 429) {
|
|
24
|
+
return "rate_limited";
|
|
25
|
+
}
|
|
26
|
+
if (response.status === 400) {
|
|
27
|
+
return "email_invalid";
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
return "code";
|
|
31
|
+
}
|
|
32
|
+
const body = await readJson(response);
|
|
33
|
+
if (typeof body !== "object" || body === null) {
|
|
34
|
+
return "code";
|
|
35
|
+
}
|
|
36
|
+
const { allowed, method } = body;
|
|
37
|
+
if (allowed === false) {
|
|
38
|
+
return "not_invited";
|
|
39
|
+
}
|
|
40
|
+
return allowed === true && method === "link" ? "link" : "code";
|
|
41
|
+
}
|
package/dist/ui/sign-in.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface SignInProps extends SignInOptions {
|
|
|
4
4
|
readonly title?: string;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Email
|
|
7
|
+
* Email sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
|
|
8
8
|
* with their own design system render `useSignIn()` themselves.
|
|
9
9
|
*/
|
|
10
10
|
export declare function SignIn({ title, ...options }: SignInProps): import("react").JSX.Element;
|
package/dist/ui/sign-in.js
CHANGED
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useId, useRef } from "react";
|
|
4
4
|
import { useSignIn } from "./use-sign-in.js";
|
|
5
5
|
/**
|
|
6
|
-
* Email
|
|
6
|
+
* Email sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
|
|
7
7
|
* with their own design system render `useSignIn()` themselves.
|
|
8
8
|
*/
|
|
9
9
|
export function SignIn({ title = "Sign in", ...options }) {
|
|
@@ -24,10 +24,15 @@ export function SignIn({ title = "Sign in", ...options }) {
|
|
|
24
24
|
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "You are signed in" }), _jsx("p", { className: "ss-text", children: sessionEmail === null ? "You already have a session." : `Signed in as ${sessionEmail}.` }), _jsxs("div", { className: "ss-actions", children: [_jsx("a", { className: "ss-button", href: destination, children: "Continue" }), _jsx("button", { type: "button", className: "ss-button ss-button--secondary", onClick: () => void switchAccount(), children: "Use another email" })] })] }));
|
|
25
25
|
}
|
|
26
26
|
if (step === "email") {
|
|
27
|
-
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }),
|
|
27
|
+
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }), _jsx("p", { className: "ss-text", children: "We will email you a code or a link to sign in." }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
|
|
28
28
|
event.preventDefault();
|
|
29
29
|
void submitEmail();
|
|
30
|
-
}, noValidate: true, children: [linkInvalid && (_jsx("p", { className: "ss-notice", role: "status", children: "This sign-in link is not valid anymore. Enter your email to get a new code." })), _jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-email`, children: "Email" }), _jsx("input", { id: `${ids}-email`, className: "ss-input", type: "email", name: "email", autoComplete: "email", inputMode: "email", required: true, value: email, onChange: (event) => setEmail(event.target.value), disabled: pending !== null, "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "send", children: pending === "send" ? "Sending
|
|
30
|
+
}, noValidate: true, children: [linkInvalid && (_jsx("p", { className: "ss-notice", role: "status", children: "This sign-in link is not valid anymore. Enter your email to get a new code." })), _jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-email`, children: "Email" }), _jsx("input", { id: `${ids}-email`, className: "ss-input", type: "email", name: "email", autoComplete: "email", inputMode: "email", required: true, value: email, onChange: (event) => setEmail(event.target.value), disabled: pending !== null, "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "send", children: pending === "send" ? "Sending…" : "Continue" })] })] }));
|
|
31
|
+
}
|
|
32
|
+
const resendButton = (label) => (_jsx("button", { type: "button", className: "ss-link", onClick: () => void resend(), disabled: pending !== null || resendIn > 0, "aria-busy": pending === "resend", children: pending === "resend" ? "Sending…" : resendIn > 0 ? `${label} in ${resendIn} s` : label }));
|
|
33
|
+
const anotherEmail = (_jsx("button", { type: "button", className: "ss-link", onClick: changeEmail, disabled: pending !== null, children: "Use another email" }));
|
|
34
|
+
if (step === "link") {
|
|
35
|
+
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "Check your email" }), _jsxs("p", { className: "ss-text", children: ["We sent a sign-in link to ", _jsx("strong", { children: email }), ". Open it on this device to sign in."] }), error !== null && (_jsx("p", { className: "ss-error", role: "alert", children: error })), notice !== null && (_jsx("p", { className: "ss-notice", role: "status", children: notice })), _jsxs("div", { className: "ss-actions", children: [resendButton("Resend link"), anotherEmail] })] }));
|
|
31
36
|
}
|
|
32
37
|
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "Check your email" }), _jsxs("p", { className: "ss-text", children: ["Enter the ", codeLength, "-digit code we sent to ", _jsx("strong", { children: email }), "."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
|
|
33
38
|
event.preventDefault();
|
|
@@ -38,9 +43,5 @@ export function SignIn({ title = "Sign in", ...options }) {
|
|
|
38
43
|
if (digits.length === codeLength) {
|
|
39
44
|
void submitCode(digits);
|
|
40
45
|
}
|
|
41
|
-
}, disabled: pending === "verify", "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), notice !== null && (_jsx("p", { className: "ss-notice", role: "status", children: notice })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "verify", children: pending === "verify" ? "Signing in…" : "Sign in" })] }), _jsxs("div", { className: "ss-actions", children: [
|
|
42
|
-
? "Sending…"
|
|
43
|
-
: resendIn > 0
|
|
44
|
-
? `Resend code in ${resendIn} s`
|
|
45
|
-
: "Resend code" }), _jsx("button", { type: "button", className: "ss-link", onClick: changeEmail, disabled: pending !== null, children: "Use another email" })] })] }));
|
|
46
|
+
}, disabled: pending === "verify", "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), notice !== null && (_jsx("p", { className: "ss-notice", role: "status", children: notice })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "verify", children: pending === "verify" ? "Signing in…" : "Sign in" })] }), _jsxs("div", { className: "ss-actions", children: [resendButton("Resend code"), anotherEmail] })] }));
|
|
46
47
|
}
|
package/dist/ui/use-sign-in.d.ts
CHANGED
|
@@ -8,9 +8,23 @@ export interface SignInOptions {
|
|
|
8
8
|
*/
|
|
9
9
|
readonly error?: string;
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Why the last call failed. `not_invited`: the ontology allows no sign-in with this address, so no
|
|
13
|
+
* email was sent. Apps that show another language word their message from it.
|
|
14
|
+
*/
|
|
15
|
+
export type SignInErrorCode = "email_required" | "email_invalid" | "not_invited" | "code_incomplete" | "code_invalid" | "code_expired" | "too_many_attempts" | "rate_limited" | "unavailable" | "failed";
|
|
16
|
+
/** What the last call confirmed: another code, or another sign-in link, was sent. */
|
|
17
|
+
export type SignInNoticeCode = "code_resent" | "link_resent";
|
|
18
|
+
/** The SDK's sentence for each {@link SignInErrorCode}. */
|
|
19
|
+
export declare const SIGN_IN_ERROR_MESSAGES: Readonly<Record<SignInErrorCode, string>>;
|
|
20
|
+
/** Email sign-in for a form the app renders itself. The calls take values, not form events. */
|
|
12
21
|
export interface SignInController {
|
|
13
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `email`, then `code` for a member with a Neon Auth account, or `link` when a sign-in link was
|
|
24
|
+
* emailed instead: a first sign-in, whose link creates the account. The link step has nothing to
|
|
25
|
+
* submit; the person opens the link from the email.
|
|
26
|
+
*/
|
|
27
|
+
readonly step: "email" | "code" | "link";
|
|
14
28
|
readonly email: string;
|
|
15
29
|
readonly setEmail: (value: string) => void;
|
|
16
30
|
readonly code: string;
|
|
@@ -19,9 +33,13 @@ export interface SignInController {
|
|
|
19
33
|
readonly pending: "send" | "verify" | "resend" | "sign-out" | null;
|
|
20
34
|
/** A sentence for the person, from the last failed call. */
|
|
21
35
|
readonly error: string | null;
|
|
36
|
+
/** Why the last call failed, for apps that word `error` themselves. */
|
|
37
|
+
readonly errorCode: SignInErrorCode | null;
|
|
22
38
|
/** A confirmation, such as a new code having been sent. */
|
|
23
39
|
readonly notice: string | null;
|
|
24
|
-
/**
|
|
40
|
+
/** What `notice` confirms, for apps that word it themselves. */
|
|
41
|
+
readonly noticeCode: SignInNoticeCode | null;
|
|
42
|
+
/** Seconds until another code or link may be requested; 0 when it may. */
|
|
25
43
|
readonly resendIn: number;
|
|
26
44
|
/** Digits in a code: 6. */
|
|
27
45
|
readonly codeLength: number;
|
|
@@ -38,14 +56,18 @@ export interface SignInController {
|
|
|
38
56
|
readonly linkInvalid: boolean;
|
|
39
57
|
/** The signed-in address or name, for the not-invited state; null when signed out. */
|
|
40
58
|
readonly sessionEmail: string | null;
|
|
41
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Asks the ontology whether `email` may sign in, then emails a code or a sign-in link and moves to
|
|
61
|
+
* `code` or `link`. An address the ontology refuses gets `not_invited`, and no email is sent. Call
|
|
62
|
+
* it from the form's submit handler.
|
|
63
|
+
*/
|
|
42
64
|
readonly submitEmail: () => Promise<void>;
|
|
43
65
|
/**
|
|
44
66
|
* Verifies `value`, or the current `code`, and opens `destination`. A wrong code clears `code`;
|
|
45
67
|
* a second call while one is verifying is ignored, so the last digit and Enter may both submit.
|
|
46
68
|
*/
|
|
47
69
|
readonly submitCode: (value?: string) => Promise<void>;
|
|
48
|
-
/** Sends another code to the same address. */
|
|
70
|
+
/** Sends another code, or another link on the link step, to the same address. */
|
|
49
71
|
readonly resend: () => Promise<void>;
|
|
50
72
|
/** Returns to the email step. */
|
|
51
73
|
readonly changeEmail: () => void;
|
|
@@ -53,11 +75,15 @@ export interface SignInController {
|
|
|
53
75
|
readonly switchAccount: () => Promise<void>;
|
|
54
76
|
}
|
|
55
77
|
/**
|
|
56
|
-
* Email
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
78
|
+
* Email sign-in with Neon Auth, without markup. The ontology first says whether the address may
|
|
79
|
+
* sign in (`POST /api/ontology/sign-in/check`): a member with a Neon Auth account gets a six-digit
|
|
80
|
+
* code, a first sign-in gets a sign-in link, which creates the account, and an address the ontology
|
|
81
|
+
* refuses gets `not_invited` without an email. Without an ontology to ask, every address gets a
|
|
82
|
+
* code. `<SignIn />` renders it with the SDK's own styles; apps with their own design system render
|
|
83
|
+
* the controller themselves.
|
|
60
84
|
*/
|
|
61
85
|
export declare function useSignIn({ returnTo, error: reason }?: SignInOptions): SignInController;
|
|
86
|
+
/** Why a Neon Auth (Better Auth) call failed. */
|
|
87
|
+
export declare function signInErrorCode(error: unknown): SignInErrorCode;
|
|
62
88
|
/** A sentence for a Neon Auth (Better Auth) failure. */
|
|
63
89
|
export declare function signInErrorMessage(error: unknown): string;
|
package/dist/ui/use-sign-in.js
CHANGED
|
@@ -4,21 +4,47 @@ import { NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
|
|
|
4
4
|
import { safeReturnTo } from "../return-to.js";
|
|
5
5
|
import { useSession } from "./hooks.js";
|
|
6
6
|
import { navigate } from "./navigation.js";
|
|
7
|
+
import { checkSignIn } from "./sign-in-check.js";
|
|
7
8
|
import { authClient, clearSession, refreshSession } from "./store.js";
|
|
8
|
-
/** Seconds before another code may be requested. */
|
|
9
|
+
/** Seconds before another code or link may be requested. */
|
|
9
10
|
const RESEND_AFTER_SECONDS = 30;
|
|
10
11
|
const CODE_LENGTH = 6;
|
|
12
|
+
/** The SDK's sentence for each {@link SignInErrorCode}. */
|
|
13
|
+
export const SIGN_IN_ERROR_MESSAGES = {
|
|
14
|
+
email_required: "Enter your email address.",
|
|
15
|
+
email_invalid: "Enter a valid email address.",
|
|
16
|
+
not_invited: "This app has not invited this address. Check it, or ask your administrator for an invitation.",
|
|
17
|
+
code_incomplete: `Enter the ${CODE_LENGTH}-digit code from the email.`,
|
|
18
|
+
code_invalid: "That code is not right. Check the email and try again.",
|
|
19
|
+
code_expired: "That code has expired. Send a new one.",
|
|
20
|
+
too_many_attempts: "Too many wrong codes. Send a new code.",
|
|
21
|
+
rate_limited: "Too many requests. Wait a minute, then try again.",
|
|
22
|
+
unavailable: "Sign-in is unavailable right now. Try again in a moment.",
|
|
23
|
+
failed: "Sign-in failed. Try again.",
|
|
24
|
+
};
|
|
11
25
|
const noSubscription = () => () => { };
|
|
12
26
|
/** The page's own query string; empty during server rendering. */
|
|
13
27
|
function useSearchParameter(name) {
|
|
14
28
|
const search = useSyncExternalStore(noSubscription, () => window.location.search, () => "");
|
|
15
29
|
return new URLSearchParams(search).get(name);
|
|
16
30
|
}
|
|
31
|
+
function noticeFor(notice, email) {
|
|
32
|
+
switch (notice) {
|
|
33
|
+
case "code_resent":
|
|
34
|
+
return `We sent a new code to ${email}.`;
|
|
35
|
+
case "link_resent":
|
|
36
|
+
return `We sent a new sign-in link to ${email}.`;
|
|
37
|
+
case null:
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
17
41
|
/**
|
|
18
|
-
* Email
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
42
|
+
* Email sign-in with Neon Auth, without markup. The ontology first says whether the address may
|
|
43
|
+
* sign in (`POST /api/ontology/sign-in/check`): a member with a Neon Auth account gets a six-digit
|
|
44
|
+
* code, a first sign-in gets a sign-in link, which creates the account, and an address the ontology
|
|
45
|
+
* refuses gets `not_invited` without an email. Without an ontology to ask, every address gets a
|
|
46
|
+
* code. `<SignIn />` renders it with the SDK's own styles; apps with their own design system render
|
|
47
|
+
* the controller themselves.
|
|
22
48
|
*/
|
|
23
49
|
export function useSignIn({ returnTo, error: reason } = {}) {
|
|
24
50
|
const session = useSession();
|
|
@@ -28,8 +54,8 @@ export function useSignIn({ returnTo, error: reason } = {}) {
|
|
|
28
54
|
const [email, setEmail] = useState("");
|
|
29
55
|
const [code, setCode] = useState("");
|
|
30
56
|
const [pending, setPending] = useState(null);
|
|
31
|
-
const [
|
|
32
|
-
const [
|
|
57
|
+
const [errorCode, setErrorCode] = useState(null);
|
|
58
|
+
const [noticeCode, setNoticeCode] = useState(null);
|
|
33
59
|
const [resendIn, setResendIn] = useState(0);
|
|
34
60
|
const [switchingAccount, setSwitchingAccount] = useState(false);
|
|
35
61
|
const verifying = useRef(false);
|
|
@@ -49,28 +75,44 @@ export function useSignIn({ returnTo, error: reason } = {}) {
|
|
|
49
75
|
}
|
|
50
76
|
}, 1000);
|
|
51
77
|
}
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
function clearMessages() {
|
|
79
|
+
setErrorCode(null);
|
|
80
|
+
setNoticeCode(null);
|
|
81
|
+
}
|
|
82
|
+
/** Runs a Neon Auth call that emails the person; on success it holds the next one back. */
|
|
83
|
+
async function send(call) {
|
|
84
|
+
const result = await attempt(call);
|
|
54
85
|
if (result !== null) {
|
|
55
|
-
|
|
86
|
+
setErrorCode(signInErrorCode(result));
|
|
56
87
|
return false;
|
|
57
88
|
}
|
|
58
89
|
holdResend();
|
|
59
90
|
return true;
|
|
60
91
|
}
|
|
92
|
+
function sendCode(address) {
|
|
93
|
+
return send(() => authClient().emailOtp.sendVerificationOtp({ email: address, type: "sign-in" }));
|
|
94
|
+
}
|
|
95
|
+
function sendLink(address) {
|
|
96
|
+
// Neon Auth checks the callback against the app's trusted origins, so it is absolute.
|
|
97
|
+
const callbackURL = new URL(destination, window.location.origin).href;
|
|
98
|
+
return send(() => authClient().signIn.magicLink({ email: address, callbackURL }));
|
|
99
|
+
}
|
|
61
100
|
async function submitEmail() {
|
|
62
101
|
const address = email.trim();
|
|
63
102
|
if (address === "") {
|
|
64
|
-
|
|
103
|
+
setErrorCode("email_required");
|
|
65
104
|
return;
|
|
66
105
|
}
|
|
67
106
|
setPending("send");
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
107
|
+
clearMessages();
|
|
108
|
+
const outcome = await checkSignIn(address);
|
|
109
|
+
if (outcome !== "code" && outcome !== "link") {
|
|
110
|
+
setErrorCode(outcome);
|
|
111
|
+
}
|
|
112
|
+
else if (await (outcome === "code" ? sendCode(address) : sendLink(address))) {
|
|
71
113
|
setEmail(address);
|
|
72
114
|
setCode("");
|
|
73
|
-
setStep(
|
|
115
|
+
setStep(outcome);
|
|
74
116
|
}
|
|
75
117
|
setPending(null);
|
|
76
118
|
}
|
|
@@ -80,17 +122,16 @@ export function useSignIn({ returnTo, error: reason } = {}) {
|
|
|
80
122
|
return;
|
|
81
123
|
}
|
|
82
124
|
if (!new RegExp(`^[0-9]{${CODE_LENGTH}}$`).test(value)) {
|
|
83
|
-
|
|
125
|
+
setErrorCode("code_incomplete");
|
|
84
126
|
return;
|
|
85
127
|
}
|
|
86
128
|
verifying.current = true;
|
|
87
129
|
setPending("verify");
|
|
88
|
-
|
|
89
|
-
setNotice(null);
|
|
130
|
+
clearMessages();
|
|
90
131
|
const result = await attempt(() => authClient().signIn.emailOtp({ email, otp: value }));
|
|
91
132
|
if (result !== null) {
|
|
92
133
|
verifying.current = false;
|
|
93
|
-
|
|
134
|
+
setErrorCode(signInErrorCode(result));
|
|
94
135
|
setCode("");
|
|
95
136
|
setPending(null);
|
|
96
137
|
return;
|
|
@@ -99,23 +140,26 @@ export function useSignIn({ returnTo, error: reason } = {}) {
|
|
|
99
140
|
}
|
|
100
141
|
async function resend() {
|
|
101
142
|
setPending("resend");
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
143
|
+
clearMessages();
|
|
144
|
+
if (step === "link") {
|
|
145
|
+
if (await sendLink(email)) {
|
|
146
|
+
setNoticeCode("link_resent");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (await sendCode(email)) {
|
|
105
150
|
setCode("");
|
|
106
|
-
|
|
151
|
+
setNoticeCode("code_resent");
|
|
107
152
|
}
|
|
108
153
|
setPending(null);
|
|
109
154
|
}
|
|
110
155
|
function changeEmail() {
|
|
111
156
|
setStep("email");
|
|
112
157
|
setCode("");
|
|
113
|
-
|
|
114
|
-
setNotice(null);
|
|
158
|
+
clearMessages();
|
|
115
159
|
}
|
|
116
160
|
async function switchAccount() {
|
|
117
161
|
setPending("sign-out");
|
|
118
|
-
|
|
162
|
+
setErrorCode(null);
|
|
119
163
|
await attempt(() => authClient().signOut());
|
|
120
164
|
clearSession();
|
|
121
165
|
await refreshSession();
|
|
@@ -130,8 +174,10 @@ export function useSignIn({ returnTo, error: reason } = {}) {
|
|
|
130
174
|
code,
|
|
131
175
|
setCode,
|
|
132
176
|
pending,
|
|
133
|
-
error,
|
|
134
|
-
|
|
177
|
+
error: errorCode === null ? null : SIGN_IN_ERROR_MESSAGES[errorCode],
|
|
178
|
+
errorCode,
|
|
179
|
+
notice: noticeFor(noticeCode, email),
|
|
180
|
+
noticeCode,
|
|
135
181
|
resendIn,
|
|
136
182
|
codeLength: CODE_LENGTH,
|
|
137
183
|
destination,
|
|
@@ -156,8 +202,8 @@ async function attempt(call) {
|
|
|
156
202
|
return error ?? new Error("Sign-in failed.");
|
|
157
203
|
}
|
|
158
204
|
}
|
|
159
|
-
/**
|
|
160
|
-
export function
|
|
205
|
+
/** Why a Neon Auth (Better Auth) call failed. */
|
|
206
|
+
export function signInErrorCode(error) {
|
|
161
207
|
const details = typeof error === "object" && error !== null ? error : {};
|
|
162
208
|
const status = typeof details.status === "number" ? details.status : undefined;
|
|
163
209
|
const text = [details.code, details.message]
|
|
@@ -165,22 +211,26 @@ export function signInErrorMessage(error) {
|
|
|
165
211
|
.join(" ")
|
|
166
212
|
.toLowerCase();
|
|
167
213
|
if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
|
|
168
|
-
return "
|
|
214
|
+
return "rate_limited";
|
|
169
215
|
}
|
|
170
216
|
if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
|
|
171
|
-
return "
|
|
217
|
+
return "too_many_attempts";
|
|
172
218
|
}
|
|
173
219
|
if (text.includes("expired")) {
|
|
174
|
-
return "
|
|
220
|
+
return "code_expired";
|
|
175
221
|
}
|
|
176
222
|
if (text.includes("otp") || text.includes("code")) {
|
|
177
|
-
return "
|
|
223
|
+
return "code_invalid";
|
|
178
224
|
}
|
|
179
225
|
if (text.includes("email")) {
|
|
180
|
-
return "
|
|
226
|
+
return "email_invalid";
|
|
181
227
|
}
|
|
182
228
|
if (status === undefined || status >= 500 || text.includes("network")) {
|
|
183
|
-
return "
|
|
229
|
+
return "unavailable";
|
|
184
230
|
}
|
|
185
|
-
return "
|
|
231
|
+
return "failed";
|
|
232
|
+
}
|
|
233
|
+
/** A sentence for a Neon Auth (Better Auth) failure. */
|
|
234
|
+
export function signInErrorMessage(error) {
|
|
235
|
+
return SIGN_IN_ERROR_MESSAGES[signInErrorCode(error)];
|
|
186
236
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Sign-in, users, permissions, the ontology client and warehouse data access for SegmentStream workspace apps built with Next.js.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|