@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 +123 -13
- package/dist/ui/index.d.ts +3 -1
- package/dist/ui/index.js +4 -2
- package/dist/ui/sign-in-check.d.ts +14 -0
- package/dist/ui/sign-in-check.js +41 -0
- package/dist/ui/sign-in.d.ts +5 -15
- package/dist/ui/sign-in.js +26 -179
- package/dist/ui/use-sign-in.d.ts +89 -0
- package/dist/ui/use-sign-in.js +236 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Sign-in, users, permissions, the ontology client and warehouse data access for
|
|
4
4
|
SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
|
|
5
|
-
React 19. The app's users sign in with an email code
|
|
5
|
+
React 19. The app's users sign in with an email code, or a sign-in link the first time,
|
|
6
|
+
through Neon Auth (Managed
|
|
6
7
|
Better Auth) on the workspace's Neon project; the workspace's ontology decides who may
|
|
7
8
|
use the app and what they may do. The SDK wraps `@neondatabase/auth`, so the app never
|
|
8
9
|
configures Neon Auth itself.
|
|
@@ -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
|
|
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
|
|
130
|
-
|
|
|
131
|
-
| `<
|
|
132
|
-
| `<
|
|
133
|
-
|
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
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
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/dist/ui/sign-in.d.ts
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
15
|
-
*
|
|
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({
|
|
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;
|
package/dist/ui/sign-in.js
CHANGED
|
@@ -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
|
|
4
|
-
import {
|
|
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
|
|
20
|
-
*
|
|
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({
|
|
9
|
+
export function SignIn({ title = "Sign in", ...options }) {
|
|
24
10
|
const ids = useId();
|
|
25
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 }),
|
|
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
|
-
|
|
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
|
|
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:
|
|
152
|
-
const digits = event.target.value.replace(/\D/g, "").slice(0,
|
|
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 ===
|
|
155
|
-
void
|
|
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: [
|
|
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
|
+
"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",
|