@segmentstream-cloud/app 0.3.1 → 0.4.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.
@@ -56,11 +57,12 @@ export const { GET, POST, PUT, DELETE, PATCH } = segmentstream.authHandlers();
56
57
  The route completes emailed sign-in links (`/api/auth/magic-link/verify`) itself: it
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
- unknown link redirects to `/sign-in?error=sign_in_link_invalid`, where `<SignIn />`
60
- offers a new code.
60
+ unknown link redirects to `/sign-in?error=sign_in_link_invalid`, where `<SignIn />` (or
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
- its stylesheet once:
64
+ its stylesheet once. Apps with their own design system render their own form on
65
+ [`useSignIn()`](#your-own-sign-in-form) instead and skip the stylesheet:
64
66
 
65
67
  ```tsx
66
68
  import { SignIn } from "@segmentstream-cloud/app/ui";
@@ -124,15 +126,16 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
124
126
  (no Tailwind). Every class starts with `ss-`; the `--ss-*` custom properties on
125
127
  `:root` set colours, radius and font.
126
128
 
127
- | Export | Does |
128
- | -------------------------------------- | ---------------------------------------------------------------------------------- |
129
- | `<SignIn />` | Email, then the six-digit code; resend after 30 seconds, errors and loading states |
130
- | `<SignedIn>`, `<SignedOut>` | Render children by session state |
131
- | `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
132
- | `<UserMenu />` | Name, email, roles and sign-out |
133
- | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
134
- | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
135
- | `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 |
136
139
 
137
140
  `<Can>` only hides controls: the ontology service checks every read and action. A
138
141
  Server Component may wrap server-rendered children in `<Can>`:
@@ -143,6 +146,105 @@ Server Component may wrap server-rendered children in `<Can>`:
143
146
  </Can>
144
147
  ```
145
148
 
149
+ ### Your own sign-in form
150
+
151
+ `useSignIn({ returnTo, error })` holds everything `<SignIn />` does, without markup: the
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
163
+ Tailwind CSS and shadcn/ui in `src/components/sign-in-form.tsx`; `<SignIn />` stays for
164
+ apps without Tailwind.
165
+
166
+ ```tsx
167
+ "use client";
168
+
169
+ import { useSignIn } from "@segmentstream-cloud/app/ui";
170
+
171
+ export function SignInForm() {
172
+ const signIn = useSignIn();
173
+
174
+ if (signIn.step === "email") {
175
+ return (
176
+ <form
177
+ noValidate
178
+ onSubmit={(event) => {
179
+ event.preventDefault();
180
+ void signIn.submitEmail();
181
+ }}
182
+ >
183
+ <input
184
+ type="email"
185
+ value={signIn.email}
186
+ onChange={(event) => signIn.setEmail(event.target.value)}
187
+ />
188
+ {signIn.error !== null && <p role="alert">{signIn.error}</p>}
189
+ <button disabled={signIn.pending !== null}>Continue</button>
190
+ </form>
191
+ );
192
+ }
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
+
205
+ return (
206
+ <form
207
+ noValidate
208
+ onSubmit={(event) => {
209
+ event.preventDefault();
210
+ void signIn.submitCode();
211
+ }}
212
+ >
213
+ <input
214
+ inputMode="numeric"
215
+ autoComplete="one-time-code"
216
+ value={signIn.code}
217
+ onChange={(event) => signIn.setCode(event.target.value)}
218
+ />
219
+ {signIn.error !== null && <p role="alert">{signIn.error}</p>}
220
+ <button disabled={signIn.pending !== null}>Sign in</button>
221
+ <button type="button" onClick={() => void signIn.resend()} disabled={signIn.resendIn > 0}>
222
+ Resend code
223
+ </button>
224
+ </form>
225
+ );
226
+ }
227
+ ```
228
+
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 |
243
+
244
+ The calls take values, not form events: call `submitEmail()` and `submitCode()` from the
245
+ form's submit handler after `event.preventDefault()`. A second `submitCode()` while one
246
+ runs is ignored, so the last digit and the Enter key may both submit.
247
+
146
248
  ## The ontology
147
249
 
148
250
  `segmentstream.ontology.<entity>` calls the app's ontology service at `/api/ontology`
@@ -297,6 +399,14 @@ Add the auth route, the sign-in page and the stylesheet import; keep the legacy
297
399
  `AppUser` gained `roles` and `permissions`. Under Neon Auth, people sign in again with
298
400
  an email code, and the ontology must have invited them.
299
401
 
402
+ ## Upgrading to 0.4
403
+
404
+ `useSignIn().step` may now be `link`: render it, or a form written for 0.3 shows the code
405
+ step after a link was sent. The `<SignIn />` button reads "Continue". Deploy the
406
+ ontology with `@segmentstream-cloud/ontology` 0.4 or later to answer the sign-in check;
407
+ until then every address gets a code, as in 0.3. `errorCode` and `noticeCode` let an
408
+ app word messages in its own language without matching the English sentences.
409
+
300
410
  ## License
301
411
 
302
412
  Apache-2.0
@@ -2,8 +2,10 @@ export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
2
2
  export type { CanProps, ChildrenProps, UserMenuProps } from "./components.js";
3
3
  export { usePermissions, useSession, useUser } from "./hooks.js";
4
4
  export type { Permissions } from "./hooks.js";
5
- export { SignIn, signInErrorMessage } from "./sign-in.js";
5
+ export { SignIn } from "./sign-in.js";
6
6
  export type { SignInProps } from "./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";
7
9
  export type { SessionState, SessionUser, UserState } from "./store.js";
8
10
  export { isAllowed } from "../permissions.js";
9
11
  export type { CurrentUser } from "../me.js";
package/dist/ui/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
  // Client components for apps that sign in with Neon Auth. Import the stylesheet once, in the root
3
- // layout: `import "@segmentstream-cloud/app/ui/styles.css";`.
3
+ // layout, when rendering the components: `import "@segmentstream-cloud/app/ui/styles.css";`. The
4
+ // hooks, `useSignIn()` among them, need no stylesheet.
4
5
  export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
5
6
  export { usePermissions, useSession, useUser } from "./hooks.js";
6
- export { SignIn, signInErrorMessage } from "./sign-in.js";
7
+ export { SignIn } from "./sign-in.js";
8
+ export { SIGN_IN_ERROR_MESSAGES, signInErrorCode, signInErrorMessage, useSignIn, } from "./use-sign-in.js";
7
9
  export { isAllowed } from "../permissions.js";
8
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
+ }
@@ -1,20 +1,10 @@
1
- export interface SignInProps {
2
- /** Where to go once signed in. Defaults to the page's `returnTo` parameter, then `/`. */
3
- readonly returnTo?: string;
4
- /**
5
- * Why the page was opened: `not_invited` when `requireUser()` found no invitation,
6
- * `sign_in_link_invalid` when an emailed sign-in link was expired, used or unknown. Defaults to
7
- * the page's `error` parameter.
8
- */
9
- readonly error?: string;
1
+ import type { SignInOptions } from "./use-sign-in.js";
2
+ export interface SignInProps extends SignInOptions {
10
3
  /** The heading. Defaults to "Sign in". */
11
4
  readonly title?: string;
12
5
  }
13
6
  /**
14
- * Email code sign-in with Neon Auth: the email address, then the six-digit code sent to it. New
15
- * addresses get an account on first sign-in; whether they may use the app is the ontology's
16
- * decision, which `requireUser()` reports as `not_invited`.
7
+ * Email sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
8
+ * with their own design system render `useSignIn()` themselves.
17
9
  */
18
- export declare function SignIn({ returnTo, error: reason, title }: SignInProps): import("react").JSX.Element;
19
- /** A sentence for a Neon Auth (Better Auth) failure. */
20
- export declare function signInErrorMessage(error: unknown): string;
10
+ export declare function SignIn({ title, ...options }: SignInProps): import("react").JSX.Element;
@@ -1,200 +1,47 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
4
- import { SIGN_IN_LINK_INVALID } from "../errors.js";
5
- import { safeReturnTo } from "../return-to.js";
6
- import { useSession } from "./hooks.js";
7
- import { navigate } from "./navigation.js";
8
- import { NOT_INVITED, authClient, clearSession, refreshSession } from "./store.js";
9
- /** Seconds before another code may be requested. */
10
- const RESEND_AFTER_SECONDS = 30;
11
- const CODE_LENGTH = 6;
12
- const noSubscription = () => () => { };
13
- /** The page's own query string; empty during server rendering. */
14
- function useSearchParameter(name) {
15
- const search = useSyncExternalStore(noSubscription, () => window.location.search, () => "");
16
- return new URLSearchParams(search).get(name);
17
- }
3
+ import { useEffect, useId, useRef } from "react";
4
+ import { useSignIn } from "./use-sign-in.js";
18
5
  /**
19
- * Email code sign-in with Neon Auth: the email address, then the six-digit code sent to it. New
20
- * addresses get an account on first sign-in; whether they may use the app is the ontology's
21
- * decision, which `requireUser()` reports as `not_invited`.
6
+ * Email sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
7
+ * with their own design system render `useSignIn()` themselves.
22
8
  */
23
- export function SignIn({ returnTo, error: reason, title = "Sign in" }) {
9
+ export function SignIn({ title = "Sign in", ...options }) {
24
10
  const ids = useId();
25
- const session = useSession();
26
- const pageReturnTo = useSearchParameter("returnTo");
27
- const pageError = useSearchParameter("error");
28
- const [step, setStep] = useState("email");
29
- const [email, setEmail] = useState("");
30
- const [code, setCode] = useState("");
31
- const [pending, setPending] = useState(null);
32
- const [error, setError] = useState(null);
33
- const [notice, setNotice] = useState(null);
34
- /** Seconds until another code may be requested. */
35
- const [resendIn, setResendIn] = useState(0);
36
- const [switchingAccount, setSwitchingAccount] = useState(false);
11
+ const { step, email, setEmail, code, setCode, pending, error, notice, resendIn, codeLength, destination, signedIn, notInvited, linkInvalid, sessionEmail, submitEmail, submitCode, resend, changeEmail, switchAccount, } = useSignIn(options);
37
12
  const codeInput = useRef(null);
38
- const verifying = useRef(false);
39
- const resendTimer = useRef(undefined);
40
- const destination = safeReturnTo(returnTo ?? pageReturnTo);
41
- const notInvited = (reason ?? pageError) === NOT_INVITED && !switchingAccount;
42
- const linkInvalid = (reason ?? pageError) === SIGN_IN_LINK_INVALID;
43
- useEffect(() => () => clearInterval(resendTimer.current), []);
13
+ // The code field takes focus when it appears and again after a wrong code.
44
14
  useEffect(() => {
45
- if (step === "code") {
46
- codeInput.current?.focus();
47
- }
48
- }, [step]);
49
- function holdResend() {
50
- const deadline = Date.now() + RESEND_AFTER_SECONDS * 1000;
51
- clearInterval(resendTimer.current);
52
- setResendIn(RESEND_AFTER_SECONDS);
53
- resendTimer.current = setInterval(() => {
54
- const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
55
- setResendIn(remaining);
56
- if (remaining === 0) {
57
- clearInterval(resendTimer.current);
58
- }
59
- }, 1000);
60
- }
61
- async function sendCode(address) {
62
- const result = await attempt(() => authClient().emailOtp.sendVerificationOtp({ email: address, type: "sign-in" }));
63
- if (result !== null) {
64
- setError(signInErrorMessage(result));
65
- return false;
66
- }
67
- holdResend();
68
- return true;
69
- }
70
- async function submitEmail(event) {
71
- event.preventDefault();
72
- const address = email.trim();
73
- if (address === "") {
74
- setError("Enter your email address.");
75
- return;
76
- }
77
- setPending("send");
78
- setError(null);
79
- setNotice(null);
80
- if (await sendCode(address)) {
81
- setEmail(address);
82
- setCode("");
83
- setStep("code");
84
- }
85
- setPending(null);
86
- }
87
- async function verify(value) {
88
- // The sixth digit and the Enter key may both submit the same code.
89
- if (verifying.current) {
90
- return;
91
- }
92
- if (!new RegExp(`^[0-9]{${CODE_LENGTH}}$`).test(value)) {
93
- setError(`Enter the ${CODE_LENGTH}-digit code from the email.`);
94
- return;
95
- }
96
- verifying.current = true;
97
- setPending("verify");
98
- setError(null);
99
- setNotice(null);
100
- const result = await attempt(() => authClient().signIn.emailOtp({ email, otp: value }));
101
- if (result !== null) {
102
- verifying.current = false;
103
- setError(signInErrorMessage(result));
104
- setCode("");
105
- setPending(null);
15
+ if (step === "code" && pending === null) {
106
16
  codeInput.current?.focus();
107
- return;
108
17
  }
109
- navigate(destination);
110
- }
111
- async function resend() {
112
- setPending("resend");
113
- setError(null);
114
- setNotice(null);
115
- if (await sendCode(email)) {
116
- setCode("");
117
- setNotice(`We sent a new code to ${email}.`);
118
- }
119
- setPending(null);
120
- }
121
- function changeEmail() {
122
- setStep("email");
123
- setCode("");
124
- setError(null);
125
- setNotice(null);
126
- }
127
- async function switchAccount() {
128
- setPending("sign-out");
129
- setError(null);
130
- await attempt(() => authClient().signOut());
131
- clearSession();
132
- await refreshSession();
133
- setSwitchingAccount(true);
134
- setStep("email");
135
- setPending(null);
136
- }
18
+ }, [step, pending]);
137
19
  const errorId = `${ids}-error`;
138
- const sessionEmail = session.status === "signed-in" ? (session.user.email ?? session.user.name) : null;
139
20
  if (notInvited) {
140
21
  return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "Ask for an invitation" }), _jsxs("p", { className: "ss-text", children: [sessionEmail === null ? "You are signed in" : `You are signed in as ${sessionEmail}`, ", but this app has not invited you. Ask your administrator for an invitation, or sign in with another email address."] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "button", className: "ss-button", onClick: () => void switchAccount(), disabled: pending !== null, "aria-busy": pending === "sign-out", children: pending === "sign-out" ? "Signing out…" : "Use another email" })] }));
141
22
  }
142
- if (session.status === "signed-in" && step === "email" && pending === null && !switchingAccount) {
23
+ if (signedIn && step === "email" && pending === null) {
143
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" })] })] }));
144
25
  }
145
26
  if (step === "email") {
146
- 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 ", CODE_LENGTH, "-digit code to sign in."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => void submitEmail(event), 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" })] })] }));
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
+ event.preventDefault();
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…" : "Continue" })] })] }));
147
31
  }
148
- 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 ", CODE_LENGTH, "-digit code we sent to ", _jsx("strong", { children: email }), "."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
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] })] }));
36
+ }
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) => {
149
38
  event.preventDefault();
150
- void verify(code);
151
- }, noValidate: true, children: [_jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-code`, children: "Code" }), _jsx("input", { ref: codeInput, id: `${ids}-code`, className: "ss-input ss-input--code", type: "text", name: "code", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9]*", maxLength: CODE_LENGTH, required: true, value: code, onChange: (event) => {
152
- const digits = event.target.value.replace(/\D/g, "").slice(0, CODE_LENGTH);
39
+ void submitCode();
40
+ }, noValidate: true, children: [_jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-code`, children: "Code" }), _jsx("input", { ref: codeInput, id: `${ids}-code`, className: "ss-input ss-input--code", type: "text", name: "code", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9]*", maxLength: codeLength, required: true, value: code, onChange: (event) => {
41
+ const digits = event.target.value.replace(/\D/g, "").slice(0, codeLength);
153
42
  setCode(digits);
154
- if (digits.length === CODE_LENGTH) {
155
- void verify(digits);
43
+ if (digits.length === codeLength) {
44
+ void submitCode(digits);
156
45
  }
157
- }, 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"
158
- ? "Sending…"
159
- : resendIn > 0
160
- ? `Resend code in ${resendIn} s`
161
- : "Resend code" }), _jsx("button", { type: "button", className: "ss-link", onClick: changeEmail, disabled: pending !== null, children: "Use another email" })] })] }));
162
- }
163
- /** Runs a Neon Auth client call; its failure, returned or thrown, or `null` on success. */
164
- async function attempt(call) {
165
- try {
166
- const result = await call();
167
- return result.error ?? null;
168
- }
169
- catch (error) {
170
- return error ?? new Error("Sign-in failed.");
171
- }
172
- }
173
- /** A sentence for a Neon Auth (Better Auth) failure. */
174
- export function signInErrorMessage(error) {
175
- const details = typeof error === "object" && error !== null ? error : {};
176
- const status = typeof details.status === "number" ? details.status : undefined;
177
- const text = [details.code, details.message]
178
- .filter((part) => typeof part === "string")
179
- .join(" ")
180
- .toLowerCase();
181
- if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
182
- return "Too many requests. Wait a minute, then try again.";
183
- }
184
- if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
185
- return "Too many wrong codes. Send a new code.";
186
- }
187
- if (text.includes("expired")) {
188
- return "That code has expired. Send a new one.";
189
- }
190
- if (text.includes("otp") || text.includes("code")) {
191
- return "That code is not right. Check the email and try again.";
192
- }
193
- if (text.includes("email")) {
194
- return "Enter a valid email address.";
195
- }
196
- if (status === undefined || status >= 500 || text.includes("network")) {
197
- return "Sign-in is unavailable right now. Try again in a moment.";
198
- }
199
- return "Sign-in failed. Try again.";
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] })] }));
200
47
  }
@@ -0,0 +1,89 @@
1
+ export interface SignInOptions {
2
+ /** Where to go once signed in. Defaults to the page's `returnTo` parameter, then `/`. */
3
+ readonly returnTo?: string;
4
+ /**
5
+ * Why the page was opened: `not_invited` when `requireUser()` found no invitation,
6
+ * `sign_in_link_invalid` when an emailed sign-in link was expired, used or unknown. Defaults to
7
+ * the page's `error` parameter.
8
+ */
9
+ readonly error?: string;
10
+ }
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. */
21
+ export interface SignInController {
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";
28
+ readonly email: string;
29
+ readonly setEmail: (value: string) => void;
30
+ readonly code: string;
31
+ readonly setCode: (value: string) => void;
32
+ /** The call in flight; disable the form while it is not `null`. */
33
+ readonly pending: "send" | "verify" | "resend" | "sign-out" | null;
34
+ /** A sentence for the person, from the last failed call. */
35
+ readonly error: string | null;
36
+ /** Why the last call failed, for apps that word `error` themselves. */
37
+ readonly errorCode: SignInErrorCode | null;
38
+ /** A confirmation, such as a new code having been sent. */
39
+ readonly notice: string | null;
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. */
43
+ readonly resendIn: number;
44
+ /** Digits in a code: 6. */
45
+ readonly codeLength: number;
46
+ /** The same-origin path the person returns to after signing in. */
47
+ readonly destination: string;
48
+ /**
49
+ * A session exists in this browser and the person has not chosen another account: offer to
50
+ * continue to `destination` instead of the email step.
51
+ */
52
+ readonly signedIn: boolean;
53
+ /** The page was opened for a signed-in person the app has not invited. */
54
+ readonly notInvited: boolean;
55
+ /** The emailed sign-in link was expired, used or unknown: say so on the email step. */
56
+ readonly linkInvalid: boolean;
57
+ /** The signed-in address or name, for the not-invited state; null when signed out. */
58
+ readonly sessionEmail: string | null;
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
+ */
64
+ readonly submitEmail: () => Promise<void>;
65
+ /**
66
+ * Verifies `value`, or the current `code`, and opens `destination`. A wrong code clears `code`;
67
+ * a second call while one is verifying is ignored, so the last digit and Enter may both submit.
68
+ */
69
+ readonly submitCode: (value?: string) => Promise<void>;
70
+ /** Sends another code, or another link on the link step, to the same address. */
71
+ readonly resend: () => Promise<void>;
72
+ /** Returns to the email step. */
73
+ readonly changeEmail: () => void;
74
+ /** Signs the current session out and returns to the email step. */
75
+ readonly switchAccount: () => Promise<void>;
76
+ }
77
+ /**
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.
84
+ */
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;
88
+ /** A sentence for a Neon Auth (Better Auth) failure. */
89
+ export declare function signInErrorMessage(error: unknown): string;
@@ -0,0 +1,236 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
4
+ import { safeReturnTo } from "../return-to.js";
5
+ import { useSession } from "./hooks.js";
6
+ import { navigate } from "./navigation.js";
7
+ import { checkSignIn } from "./sign-in-check.js";
8
+ import { authClient, clearSession, refreshSession } from "./store.js";
9
+ /** Seconds before another code or link may be requested. */
10
+ const RESEND_AFTER_SECONDS = 30;
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
+ };
25
+ const noSubscription = () => () => { };
26
+ /** The page's own query string; empty during server rendering. */
27
+ function useSearchParameter(name) {
28
+ const search = useSyncExternalStore(noSubscription, () => window.location.search, () => "");
29
+ return new URLSearchParams(search).get(name);
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
+ }
41
+ /**
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.
48
+ */
49
+ export function useSignIn({ returnTo, error: reason } = {}) {
50
+ const session = useSession();
51
+ const pageReturnTo = useSearchParameter("returnTo");
52
+ const pageError = useSearchParameter("error");
53
+ const [step, setStep] = useState("email");
54
+ const [email, setEmail] = useState("");
55
+ const [code, setCode] = useState("");
56
+ const [pending, setPending] = useState(null);
57
+ const [errorCode, setErrorCode] = useState(null);
58
+ const [noticeCode, setNoticeCode] = useState(null);
59
+ const [resendIn, setResendIn] = useState(0);
60
+ const [switchingAccount, setSwitchingAccount] = useState(false);
61
+ const verifying = useRef(false);
62
+ const resendTimer = useRef(undefined);
63
+ const destination = safeReturnTo(returnTo ?? pageReturnTo);
64
+ const why = reason ?? pageError;
65
+ useEffect(() => () => clearInterval(resendTimer.current), []);
66
+ function holdResend() {
67
+ const deadline = Date.now() + RESEND_AFTER_SECONDS * 1000;
68
+ clearInterval(resendTimer.current);
69
+ setResendIn(RESEND_AFTER_SECONDS);
70
+ resendTimer.current = setInterval(() => {
71
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
72
+ setResendIn(remaining);
73
+ if (remaining === 0) {
74
+ clearInterval(resendTimer.current);
75
+ }
76
+ }, 1000);
77
+ }
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);
85
+ if (result !== null) {
86
+ setErrorCode(signInErrorCode(result));
87
+ return false;
88
+ }
89
+ holdResend();
90
+ return true;
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
+ }
100
+ async function submitEmail() {
101
+ const address = email.trim();
102
+ if (address === "") {
103
+ setErrorCode("email_required");
104
+ return;
105
+ }
106
+ setPending("send");
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))) {
113
+ setEmail(address);
114
+ setCode("");
115
+ setStep(outcome);
116
+ }
117
+ setPending(null);
118
+ }
119
+ async function submitCode(value = code) {
120
+ // The last digit and the Enter key may both submit the same code.
121
+ if (verifying.current) {
122
+ return;
123
+ }
124
+ if (!new RegExp(`^[0-9]{${CODE_LENGTH}}$`).test(value)) {
125
+ setErrorCode("code_incomplete");
126
+ return;
127
+ }
128
+ verifying.current = true;
129
+ setPending("verify");
130
+ clearMessages();
131
+ const result = await attempt(() => authClient().signIn.emailOtp({ email, otp: value }));
132
+ if (result !== null) {
133
+ verifying.current = false;
134
+ setErrorCode(signInErrorCode(result));
135
+ setCode("");
136
+ setPending(null);
137
+ return;
138
+ }
139
+ navigate(destination);
140
+ }
141
+ async function resend() {
142
+ setPending("resend");
143
+ clearMessages();
144
+ if (step === "link") {
145
+ if (await sendLink(email)) {
146
+ setNoticeCode("link_resent");
147
+ }
148
+ }
149
+ else if (await sendCode(email)) {
150
+ setCode("");
151
+ setNoticeCode("code_resent");
152
+ }
153
+ setPending(null);
154
+ }
155
+ function changeEmail() {
156
+ setStep("email");
157
+ setCode("");
158
+ clearMessages();
159
+ }
160
+ async function switchAccount() {
161
+ setPending("sign-out");
162
+ setErrorCode(null);
163
+ await attempt(() => authClient().signOut());
164
+ clearSession();
165
+ await refreshSession();
166
+ setSwitchingAccount(true);
167
+ setStep("email");
168
+ setPending(null);
169
+ }
170
+ return {
171
+ step,
172
+ email,
173
+ setEmail,
174
+ code,
175
+ setCode,
176
+ pending,
177
+ error: errorCode === null ? null : SIGN_IN_ERROR_MESSAGES[errorCode],
178
+ errorCode,
179
+ notice: noticeFor(noticeCode, email),
180
+ noticeCode,
181
+ resendIn,
182
+ codeLength: CODE_LENGTH,
183
+ destination,
184
+ signedIn: session.status === "signed-in" && !switchingAccount,
185
+ notInvited: why === NOT_INVITED && !switchingAccount,
186
+ linkInvalid: why === SIGN_IN_LINK_INVALID,
187
+ sessionEmail: session.status === "signed-in" ? (session.user.email ?? session.user.name) : null,
188
+ submitEmail,
189
+ submitCode,
190
+ resend,
191
+ changeEmail,
192
+ switchAccount,
193
+ };
194
+ }
195
+ /** Runs a Neon Auth client call; its failure, returned or thrown, or `null` on success. */
196
+ async function attempt(call) {
197
+ try {
198
+ const result = await call();
199
+ return result.error ?? null;
200
+ }
201
+ catch (error) {
202
+ return error ?? new Error("Sign-in failed.");
203
+ }
204
+ }
205
+ /** Why a Neon Auth (Better Auth) call failed. */
206
+ export function signInErrorCode(error) {
207
+ const details = typeof error === "object" && error !== null ? error : {};
208
+ const status = typeof details.status === "number" ? details.status : undefined;
209
+ const text = [details.code, details.message]
210
+ .filter((part) => typeof part === "string")
211
+ .join(" ")
212
+ .toLowerCase();
213
+ if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
214
+ return "rate_limited";
215
+ }
216
+ if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
217
+ return "too_many_attempts";
218
+ }
219
+ if (text.includes("expired")) {
220
+ return "code_expired";
221
+ }
222
+ if (text.includes("otp") || text.includes("code")) {
223
+ return "code_invalid";
224
+ }
225
+ if (text.includes("email")) {
226
+ return "email_invalid";
227
+ }
228
+ if (status === undefined || status >= 500 || text.includes("network")) {
229
+ return "unavailable";
230
+ }
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)];
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",