@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 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 through Neon Auth (Managed
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 code.
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 the six-digit code; resend after 30 seconds, errors and loading states |
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, the return to
152
- `returnTo` after sign-in and the `not_invited` and `sign_in_link_invalid` states. Both
153
- options default to the page's query parameters. The workspace starter renders it with
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}>Send code</button>
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
- | `resendIn` | Seconds until `resend()` may be offered; 0 when it may |
216
- | `submitEmail()`, `submitCode(value?)` | Send the code; verify `value` or `code`. A wrong code clears `code`; refocus the field |
217
- | `resend()`, `changeEmail()` | Another code to the same address; back to the email step |
218
- | `signedIn`, `destination` | A session already exists: offer a link to `destination` |
219
- | `notInvited`, `sessionEmail` | The person is signed in as `sessionEmail` without an invitation |
220
- | `switchAccount()` | Signs out and returns to the email step |
221
- | `linkInvalid` | An emailed sign-in link was expired or used: say so on the email step |
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.semantic` (governed metrics from semantic models) and
308
- `segmentstream.fetch(path)` (any SegmentStream API path) act as the signed-in
309
- SegmentStream member, so they need the legacy sign-in; under Neon Auth they throw
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`, `warehouse` and `semantic` act as the member. In that mode
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 { SemanticClient, WarehouseClient } from "./data.js";
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
- semantic: data.semantic,
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 SemanticQueryRequest {
47
- readonly dimensions: readonly string[];
48
- readonly measures: readonly string[];
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: string;
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?: string;
62
- readonly dimension?: string;
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 SemanticField {
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 SemanticQueryResult<Row = Record<string, string | boolean | null>> {
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 SemanticField[];
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
- semantic: SemanticClient;
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 semantic = {
158
- list: () => call("GET", `${workspace}/semantic-models`),
159
- get: (model) => call("GET", `${workspace}/semantic-models/${encodeURIComponent(model)}`),
160
- query: (model, request) => call("POST", `${workspace}/semantic-models/${encodeURIComponent(model)}/query`, request),
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, semantic };
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 { SemanticClient, SemanticField, SemanticQueryRequest, SemanticQueryResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
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";
@@ -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>>]: EntityClient<RecordOf<Extract<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 = entityClient(property, transport);
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;
@@ -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
+ }
@@ -4,7 +4,7 @@ export interface SignInProps extends SignInOptions {
4
4
  readonly title?: string;
5
5
  }
6
6
  /**
7
- * Email code sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
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;
@@ -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 code sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
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 }), _jsxs("p", { className: "ss-text", children: ["We will email you a ", codeLength, "-digit code to sign in."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
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 code…" : "Send code" })] })] }));
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: [_jsx("button", { type: "button", className: "ss-link", onClick: () => void resend(), disabled: pending !== null || resendIn > 0, "aria-busy": pending === "resend", children: pending === "resend"
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
  }
@@ -8,9 +8,23 @@ export interface SignInOptions {
8
8
  */
9
9
  readonly error?: string;
10
10
  }
11
- /** Email code sign-in for a form the app renders itself. The calls take values, not form events. */
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
- readonly step: "email" | "code";
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
- /** Seconds until another code may be requested; 0 when it may. */
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
- /** Sends a code to `email` and moves to the code step. Call it from the form's submit handler. */
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 code sign-in with Neon Auth, without markup: the email address, then the six-digit code
57
- * sent to it. New addresses get an account on first sign-in; whether they may use the app is the
58
- * ontology's decision, which `requireUser()` reports as `not_invited`. `<SignIn />` renders it with
59
- * the SDK's own styles; apps with their own design system render the controller themselves.
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;
@@ -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 code sign-in with Neon Auth, without markup: the email address, then the six-digit code
19
- * sent to it. New addresses get an account on first sign-in; whether they may use the app is the
20
- * ontology's decision, which `requireUser()` reports as `not_invited`. `<SignIn />` renders it with
21
- * the SDK's own styles; apps with their own design system render the controller themselves.
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 [error, setError] = useState(null);
32
- const [notice, setNotice] = useState(null);
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
- async function sendCode(address) {
53
- const result = await attempt(() => authClient().emailOtp.sendVerificationOtp({ email: address, type: "sign-in" }));
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
- setError(signInErrorMessage(result));
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
- setError("Enter your email address.");
103
+ setErrorCode("email_required");
65
104
  return;
66
105
  }
67
106
  setPending("send");
68
- setError(null);
69
- setNotice(null);
70
- if (await sendCode(address)) {
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("code");
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
- setError(`Enter the ${CODE_LENGTH}-digit code from the email.`);
125
+ setErrorCode("code_incomplete");
84
126
  return;
85
127
  }
86
128
  verifying.current = true;
87
129
  setPending("verify");
88
- setError(null);
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
- setError(signInErrorMessage(result));
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
- setError(null);
103
- setNotice(null);
104
- if (await sendCode(email)) {
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
- setNotice(`We sent a new code to ${email}.`);
151
+ setNoticeCode("code_resent");
107
152
  }
108
153
  setPending(null);
109
154
  }
110
155
  function changeEmail() {
111
156
  setStep("email");
112
157
  setCode("");
113
- setError(null);
114
- setNotice(null);
158
+ clearMessages();
115
159
  }
116
160
  async function switchAccount() {
117
161
  setPending("sign-out");
118
- setError(null);
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
- notice,
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
- /** A sentence for a Neon Auth (Better Auth) failure. */
160
- export function signInErrorMessage(error) {
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 "Too many requests. Wait a minute, then try again.";
214
+ return "rate_limited";
169
215
  }
170
216
  if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
171
- return "Too many wrong codes. Send a new code.";
217
+ return "too_many_attempts";
172
218
  }
173
219
  if (text.includes("expired")) {
174
- return "That code has expired. Send a new one.";
220
+ return "code_expired";
175
221
  }
176
222
  if (text.includes("otp") || text.includes("code")) {
177
- return "That code is not right. Check the email and try again.";
223
+ return "code_invalid";
178
224
  }
179
225
  if (text.includes("email")) {
180
- return "Enter a valid email address.";
226
+ return "email_invalid";
181
227
  }
182
228
  if (status === undefined || status >= 500 || text.includes("network")) {
183
- return "Sign-in is unavailable right now. Try again in a moment.";
229
+ return "unavailable";
184
230
  }
185
- return "Sign-in failed. Try again.";
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.2",
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",