@segmentstream-cloud/app 0.3.0 → 0.3.2
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 +88 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/neon.js +59 -2
- package/dist/ui/index.d.ts +4 -2
- package/dist/ui/index.js +5 -3
- package/dist/ui/sign-in.d.ts +5 -14
- package/dist/ui/sign-in.js +20 -172
- package/dist/ui/use-sign-in.d.ts +63 -0
- package/dist/ui/use-sign-in.js +186 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,8 +53,15 @@ import { segmentstream } from "@/segmentstream";
|
|
|
53
53
|
export const { GET, POST, PUT, DELETE, PATCH } = segmentstream.authHandlers();
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
The route completes emailed sign-in links (`/api/auth/magic-link/verify`) itself: it
|
|
57
|
+
verifies the token through Neon Auth without a callback, sets the session cookies on
|
|
58
|
+
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 />` (or
|
|
60
|
+
`useSignIn().linkInvalid`) offers a new code.
|
|
61
|
+
|
|
56
62
|
**`src/app/sign-in/page.tsx`** renders the sign-in form, and the root layout imports
|
|
57
|
-
its stylesheet once
|
|
63
|
+
its stylesheet once. Apps with their own design system render their own form on
|
|
64
|
+
[`useSignIn()`](#your-own-sign-in-form) instead and skip the stylesheet:
|
|
58
65
|
|
|
59
66
|
```tsx
|
|
60
67
|
import { SignIn } from "@segmentstream-cloud/app/ui";
|
|
@@ -121,6 +128,7 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
|
|
|
121
128
|
| Export | Does |
|
|
122
129
|
| -------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
123
130
|
| `<SignIn />` | Email, then the six-digit code; resend after 30 seconds, errors and loading states |
|
|
131
|
+
| `useSignIn()` | The same sign-in without markup, for the app's own form |
|
|
124
132
|
| `<SignedIn>`, `<SignedOut>` | Render children by session state |
|
|
125
133
|
| `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
|
|
126
134
|
| `<UserMenu />` | Name, email, roles and sign-out |
|
|
@@ -137,6 +145,85 @@ Server Component may wrap server-rendered children in `<Can>`:
|
|
|
137
145
|
</Can>
|
|
138
146
|
```
|
|
139
147
|
|
|
148
|
+
### Your own sign-in form
|
|
149
|
+
|
|
150
|
+
`useSignIn({ returnTo, error })` holds everything `<SignIn />` does, without markup: the
|
|
151
|
+
steps, the 30-second resend hold, the six-digit check, error sentences, the return to
|
|
152
|
+
`returnTo` after sign-in and the `not_invited` and `sign_in_link_invalid` states. Both
|
|
153
|
+
options default to the page's query parameters. The workspace starter renders it with
|
|
154
|
+
Tailwind CSS and shadcn/ui in `src/components/sign-in-form.tsx`; `<SignIn />` stays for
|
|
155
|
+
apps without Tailwind.
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
"use client";
|
|
159
|
+
|
|
160
|
+
import { useSignIn } from "@segmentstream-cloud/app/ui";
|
|
161
|
+
|
|
162
|
+
export function SignInForm() {
|
|
163
|
+
const signIn = useSignIn();
|
|
164
|
+
|
|
165
|
+
if (signIn.step === "email") {
|
|
166
|
+
return (
|
|
167
|
+
<form
|
|
168
|
+
noValidate
|
|
169
|
+
onSubmit={(event) => {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
void signIn.submitEmail();
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<input
|
|
175
|
+
type="email"
|
|
176
|
+
value={signIn.email}
|
|
177
|
+
onChange={(event) => signIn.setEmail(event.target.value)}
|
|
178
|
+
/>
|
|
179
|
+
{signIn.error !== null && <p role="alert">{signIn.error}</p>}
|
|
180
|
+
<button disabled={signIn.pending !== null}>Send code</button>
|
|
181
|
+
</form>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<form
|
|
187
|
+
noValidate
|
|
188
|
+
onSubmit={(event) => {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
void signIn.submitCode();
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<input
|
|
194
|
+
inputMode="numeric"
|
|
195
|
+
autoComplete="one-time-code"
|
|
196
|
+
value={signIn.code}
|
|
197
|
+
onChange={(event) => signIn.setCode(event.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
{signIn.error !== null && <p role="alert">{signIn.error}</p>}
|
|
200
|
+
<button disabled={signIn.pending !== null}>Sign in</button>
|
|
201
|
+
<button type="button" onClick={() => void signIn.resend()} disabled={signIn.resendIn > 0}>
|
|
202
|
+
Resend code
|
|
203
|
+
</button>
|
|
204
|
+
</form>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
| Field | Means |
|
|
210
|
+
| -------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
211
|
+
| `step` | `email`, then `code` once a code is sent |
|
|
212
|
+
| `email`, `setEmail`, `code`, `setCode` | The field values; `submitEmail()` trims the address |
|
|
213
|
+
| `pending` | `send`, `verify`, `resend` or `sign-out` while a call runs; disable the form |
|
|
214
|
+
| `error`, `notice` | A sentence for the person, and a confirmation such as a resent code |
|
|
215
|
+
| `resendIn` | Seconds until `resend()` may be offered; 0 when it may |
|
|
216
|
+
| `submitEmail()`, `submitCode(value?)` | Send the code; verify `value` or `code`. A wrong code clears `code`; refocus the field |
|
|
217
|
+
| `resend()`, `changeEmail()` | Another code to the same address; back to the email step |
|
|
218
|
+
| `signedIn`, `destination` | A session already exists: offer a link to `destination` |
|
|
219
|
+
| `notInvited`, `sessionEmail` | The person is signed in as `sessionEmail` without an invitation |
|
|
220
|
+
| `switchAccount()` | Signs out and returns to the email step |
|
|
221
|
+
| `linkInvalid` | An emailed sign-in link was expired or used: say so on the email step |
|
|
222
|
+
|
|
223
|
+
The calls take values, not form events: call `submitEmail()` and `submitCode()` from the
|
|
224
|
+
form's submit handler after `event.preventDefault()`. A second `submitCode()` while one
|
|
225
|
+
runs is ignored, so the last digit and the Enter key may both submit.
|
|
226
|
+
|
|
140
227
|
## The ontology
|
|
141
228
|
|
|
142
229
|
`segmentstream.ontology.<entity>` calls the app's ontology service at `/api/ontology`
|
package/dist/errors.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export declare class SegmentStreamApiError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
/** The person signed in, but the ontology has no member row for them. */
|
|
33
33
|
export declare const NOT_INVITED = "not_invited";
|
|
34
|
+
/** An emailed sign-in link that Neon Auth rejected: expired, already used or unknown. */
|
|
35
|
+
export declare const SIGN_IN_LINK_INVALID = "sign_in_link_invalid";
|
|
34
36
|
/** The error body of a failed response: `{ error, detail, requestId, issues? }`. */
|
|
35
37
|
export declare function apiErrorFrom(status: number, payload: unknown, fallbackDetail: string): SegmentStreamApiError;
|
|
36
38
|
export declare function readJson(response: Response): Promise<unknown>;
|
package/dist/errors.js
CHANGED
|
@@ -26,6 +26,8 @@ export class SegmentStreamApiError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
/** The person signed in, but the ontology has no member row for them. */
|
|
28
28
|
export const NOT_INVITED = "not_invited";
|
|
29
|
+
/** An emailed sign-in link that Neon Auth rejected: expired, already used or unknown. */
|
|
30
|
+
export const SIGN_IN_LINK_INVALID = "sign_in_link_invalid";
|
|
29
31
|
/** The error body of a failed response: `{ error, detail, requestId, issues? }`. */
|
|
30
32
|
export function apiErrorFrom(status, payload, fallbackDetail) {
|
|
31
33
|
const body = typeof payload === "object" && payload !== null ? payload : {};
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { SegmentStreamAppConfig } from "./config.js";
|
|
|
15
15
|
* from the ontology. Without it, members sign in with their SegmentStream account, as in 0.2.
|
|
16
16
|
*/
|
|
17
17
|
export declare function createSegmentStreamApp<O = unknown>(config: SegmentStreamAppConfig): SegmentStreamApp<O>;
|
|
18
|
-
export { NOT_INVITED, SegmentStreamApiError } from "./errors.js";
|
|
18
|
+
export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
|
|
19
19
|
export { grants, isAllowed } from "./permissions.js";
|
|
20
20
|
export { sql } from "./parameters.js";
|
|
21
21
|
export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,6 @@ import { createApp } from "./app.js";
|
|
|
16
16
|
export function createSegmentStreamApp(config) {
|
|
17
17
|
return createApp(config);
|
|
18
18
|
}
|
|
19
|
-
export { NOT_INVITED, SegmentStreamApiError } from "./errors.js";
|
|
19
|
+
export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
|
|
20
20
|
export { grants, isAllowed } from "./permissions.js";
|
|
21
21
|
export { sql } from "./parameters.js";
|
package/dist/neon.js
CHANGED
|
@@ -4,7 +4,7 @@ import { cookies } from "next/headers";
|
|
|
4
4
|
import { redirect } from "next/navigation";
|
|
5
5
|
import { NextResponse } from "next/server";
|
|
6
6
|
import { replaceRequestCookies, safeReturnTo } from "./cookies.js";
|
|
7
|
-
import { NOT_INVITED, SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
|
|
7
|
+
import { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError, apiErrorFrom, readJson, } from "./errors.js";
|
|
8
8
|
import { parseMe } from "./me.js";
|
|
9
9
|
import { createOntologyTransport } from "./ontology-transport.js";
|
|
10
10
|
import { isAllowed } from "./permissions.js";
|
|
@@ -28,6 +28,12 @@ export const OPEN_PATHS = [
|
|
|
28
28
|
"/sitemap.xml",
|
|
29
29
|
"/manifest.webmanifest",
|
|
30
30
|
];
|
|
31
|
+
/**
|
|
32
|
+
* Better Auth answers a magic link that names a callback with a redirect to that path on Neon
|
|
33
|
+
* Auth's own origin; Neon's proxy follows it and loses the session cookie. Without them it answers
|
|
34
|
+
* JSON with the cookies, and the app's auth route redirects itself.
|
|
35
|
+
*/
|
|
36
|
+
const MAGIC_LINK_CALLBACK_PARAMETERS = ["callbackURL", "errorCallbackURL", "newUserCallbackURL"];
|
|
31
37
|
/** How long a user's roles and permissions are reused before `/api/ontology/me` is asked again. */
|
|
32
38
|
const USER_TTL_MS = 60_000;
|
|
33
39
|
/** Neon Auth JWTs live 15 minutes; a cached one is replaced a minute before it expires. */
|
|
@@ -281,7 +287,12 @@ export function createNeonSession(dependencies) {
|
|
|
281
287
|
function authHandlers() {
|
|
282
288
|
const handlers = () => (routeHandlers ??= neonAuth().handler());
|
|
283
289
|
return {
|
|
284
|
-
GET: (request, context) =>
|
|
290
|
+
GET: async (request, context) => {
|
|
291
|
+
const { path } = await context.params;
|
|
292
|
+
return path.length === 2 && path[0] === "magic-link" && path[1] === "verify"
|
|
293
|
+
? completeMagicLink(request, context, () => handlers().GET)
|
|
294
|
+
: handlers().GET(request, context);
|
|
295
|
+
},
|
|
285
296
|
POST: (request, context) => handlers().POST(request, context),
|
|
286
297
|
PUT: (request, context) => handlers().PUT(request, context),
|
|
287
298
|
DELETE: (request, context) => handlers().DELETE(request, context),
|
|
@@ -375,6 +386,52 @@ function forwardSessionCookies(request, response) {
|
|
|
375
386
|
}
|
|
376
387
|
return forwarded;
|
|
377
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Signs in with an emailed magic link on the app's own domain: Neon Auth verifies the token
|
|
391
|
+
* without a callback and sets the session cookies, which go to the browser with a redirect to the
|
|
392
|
+
* link's `callbackURL` path. A link Neon Auth rejects leads to the sign-in page instead.
|
|
393
|
+
*/
|
|
394
|
+
async function completeMagicLink(request, context, verify) {
|
|
395
|
+
const url = new URL(request.url);
|
|
396
|
+
const destination = safeReturnTo(url.searchParams.get("callbackURL"));
|
|
397
|
+
const token = url.searchParams.get("token");
|
|
398
|
+
if (token === null || token === "") {
|
|
399
|
+
return invalidSignInLink(url.origin, destination);
|
|
400
|
+
}
|
|
401
|
+
for (const name of MAGIC_LINK_CALLBACK_PARAMETERS) {
|
|
402
|
+
url.searchParams.delete(name);
|
|
403
|
+
}
|
|
404
|
+
const handler = verify();
|
|
405
|
+
let response;
|
|
406
|
+
try {
|
|
407
|
+
response = await handler(new Request(url, request), context);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return invalidSignInLink(url.origin, destination);
|
|
411
|
+
}
|
|
412
|
+
// The body holds the session token; only the cookies go on to the browser.
|
|
413
|
+
void response.body?.cancel().catch(() => undefined);
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
return invalidSignInLink(url.origin, destination);
|
|
416
|
+
}
|
|
417
|
+
const redirect = NextResponse.redirect(new URL(destination, url.origin), 303);
|
|
418
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
419
|
+
redirect.headers.append("set-cookie", cookie);
|
|
420
|
+
}
|
|
421
|
+
redirect.headers.set("cache-control", "no-store");
|
|
422
|
+
return redirect;
|
|
423
|
+
}
|
|
424
|
+
/** An expired, used or unknown sign-in link: the sign-in page explains it and sends a code. */
|
|
425
|
+
function invalidSignInLink(origin, destination) {
|
|
426
|
+
const url = new URL(SIGN_IN_PAGE, origin);
|
|
427
|
+
url.searchParams.set("error", SIGN_IN_LINK_INVALID);
|
|
428
|
+
if (destination !== "/") {
|
|
429
|
+
url.searchParams.set("returnTo", destination);
|
|
430
|
+
}
|
|
431
|
+
const response = NextResponse.redirect(url, 303);
|
|
432
|
+
response.headers.set("cache-control", "no-store");
|
|
433
|
+
return response;
|
|
434
|
+
}
|
|
378
435
|
/** Page navigations go to the sign-in page and come back to where they started. */
|
|
379
436
|
function signInPageRedirect(request) {
|
|
380
437
|
const url = new URL(SIGN_IN_PAGE, request.nextUrl.origin);
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -2,9 +2,11 @@ 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 { signInErrorMessage, useSignIn } from "./use-sign-in.js";
|
|
8
|
+
export type { SignInController, 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";
|
|
10
|
-
export { SegmentStreamApiError, NOT_INVITED } from "../errors.js";
|
|
12
|
+
export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.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 { signInErrorMessage, useSignIn } from "./use-sign-in.js";
|
|
7
9
|
export { isAllowed } from "../permissions.js";
|
|
8
|
-
export { SegmentStreamApiError, NOT_INVITED } from "../errors.js";
|
|
10
|
+
export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
|
package/dist/ui/sign-in.d.ts
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
readonly returnTo?: string;
|
|
4
|
-
/**
|
|
5
|
-
* Why the page was opened: `not_invited` when `requireUser()` found no invitation. Defaults to
|
|
6
|
-
* the page's `error` parameter.
|
|
7
|
-
*/
|
|
8
|
-
readonly error?: string;
|
|
1
|
+
import type { SignInOptions } from "./use-sign-in.js";
|
|
2
|
+
export interface SignInProps extends SignInOptions {
|
|
9
3
|
/** The heading. Defaults to "Sign in". */
|
|
10
4
|
readonly title?: string;
|
|
11
5
|
}
|
|
12
6
|
/**
|
|
13
|
-
* Email code sign-in with Neon Auth
|
|
14
|
-
*
|
|
15
|
-
* decision, which `requireUser()` reports as `not_invited`.
|
|
7
|
+
* Email code sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
|
|
8
|
+
* with their own design system render `useSignIn()` themselves.
|
|
16
9
|
*/
|
|
17
|
-
export declare function SignIn({
|
|
18
|
-
/** A sentence for a Neon Auth (Better Auth) failure. */
|
|
19
|
-
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,156 +1,42 @@
|
|
|
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 { useSession } from "./hooks.js";
|
|
6
|
-
import { navigate } from "./navigation.js";
|
|
7
|
-
import { NOT_INVITED, authClient, clearSession, refreshSession } from "./store.js";
|
|
8
|
-
/** Seconds before another code may be requested. */
|
|
9
|
-
const RESEND_AFTER_SECONDS = 30;
|
|
10
|
-
const CODE_LENGTH = 6;
|
|
11
|
-
const noSubscription = () => () => { };
|
|
12
|
-
/** The page's own query string; empty during server rendering. */
|
|
13
|
-
function useSearchParameter(name) {
|
|
14
|
-
const search = useSyncExternalStore(noSubscription, () => window.location.search, () => "");
|
|
15
|
-
return new URLSearchParams(search).get(name);
|
|
16
|
-
}
|
|
3
|
+
import { useEffect, useId, useRef } from "react";
|
|
4
|
+
import { useSignIn } from "./use-sign-in.js";
|
|
17
5
|
/**
|
|
18
|
-
* Email code sign-in with Neon Auth
|
|
19
|
-
*
|
|
20
|
-
* decision, which `requireUser()` reports as `not_invited`.
|
|
6
|
+
* Email code sign-in with Neon Auth in the SDK's neutral styles: `useSignIn()` with markup. Apps
|
|
7
|
+
* with their own design system render `useSignIn()` themselves.
|
|
21
8
|
*/
|
|
22
|
-
export function SignIn({
|
|
9
|
+
export function SignIn({ title = "Sign in", ...options }) {
|
|
23
10
|
const ids = useId();
|
|
24
|
-
const
|
|
25
|
-
const pageReturnTo = useSearchParameter("returnTo");
|
|
26
|
-
const pageError = useSearchParameter("error");
|
|
27
|
-
const [step, setStep] = useState("email");
|
|
28
|
-
const [email, setEmail] = useState("");
|
|
29
|
-
const [code, setCode] = useState("");
|
|
30
|
-
const [pending, setPending] = useState(null);
|
|
31
|
-
const [error, setError] = useState(null);
|
|
32
|
-
const [notice, setNotice] = useState(null);
|
|
33
|
-
/** Seconds until another code may be requested. */
|
|
34
|
-
const [resendIn, setResendIn] = useState(0);
|
|
35
|
-
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);
|
|
36
12
|
const codeInput = useRef(null);
|
|
37
|
-
|
|
38
|
-
const resendTimer = useRef(undefined);
|
|
39
|
-
const destination = safeReturnTo(returnTo ?? pageReturnTo);
|
|
40
|
-
const notInvited = (reason ?? pageError) === NOT_INVITED && !switchingAccount;
|
|
41
|
-
useEffect(() => () => clearInterval(resendTimer.current), []);
|
|
13
|
+
// The code field takes focus when it appears and again after a wrong code.
|
|
42
14
|
useEffect(() => {
|
|
43
|
-
if (step === "code") {
|
|
15
|
+
if (step === "code" && pending === null) {
|
|
44
16
|
codeInput.current?.focus();
|
|
45
17
|
}
|
|
46
|
-
}, [step]);
|
|
47
|
-
function holdResend() {
|
|
48
|
-
const deadline = Date.now() + RESEND_AFTER_SECONDS * 1000;
|
|
49
|
-
clearInterval(resendTimer.current);
|
|
50
|
-
setResendIn(RESEND_AFTER_SECONDS);
|
|
51
|
-
resendTimer.current = setInterval(() => {
|
|
52
|
-
const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
53
|
-
setResendIn(remaining);
|
|
54
|
-
if (remaining === 0) {
|
|
55
|
-
clearInterval(resendTimer.current);
|
|
56
|
-
}
|
|
57
|
-
}, 1000);
|
|
58
|
-
}
|
|
59
|
-
async function sendCode(address) {
|
|
60
|
-
const result = await attempt(() => authClient().emailOtp.sendVerificationOtp({ email: address, type: "sign-in" }));
|
|
61
|
-
if (result !== null) {
|
|
62
|
-
setError(signInErrorMessage(result));
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
holdResend();
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
async function submitEmail(event) {
|
|
69
|
-
event.preventDefault();
|
|
70
|
-
const address = email.trim();
|
|
71
|
-
if (address === "") {
|
|
72
|
-
setError("Enter your email address.");
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
setPending("send");
|
|
76
|
-
setError(null);
|
|
77
|
-
setNotice(null);
|
|
78
|
-
if (await sendCode(address)) {
|
|
79
|
-
setEmail(address);
|
|
80
|
-
setCode("");
|
|
81
|
-
setStep("code");
|
|
82
|
-
}
|
|
83
|
-
setPending(null);
|
|
84
|
-
}
|
|
85
|
-
async function verify(value) {
|
|
86
|
-
// The sixth digit and the Enter key may both submit the same code.
|
|
87
|
-
if (verifying.current) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
if (!new RegExp(`^[0-9]{${CODE_LENGTH}}$`).test(value)) {
|
|
91
|
-
setError(`Enter the ${CODE_LENGTH}-digit code from the email.`);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
verifying.current = true;
|
|
95
|
-
setPending("verify");
|
|
96
|
-
setError(null);
|
|
97
|
-
setNotice(null);
|
|
98
|
-
const result = await attempt(() => authClient().signIn.emailOtp({ email, otp: value }));
|
|
99
|
-
if (result !== null) {
|
|
100
|
-
verifying.current = false;
|
|
101
|
-
setError(signInErrorMessage(result));
|
|
102
|
-
setCode("");
|
|
103
|
-
setPending(null);
|
|
104
|
-
codeInput.current?.focus();
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
navigate(destination);
|
|
108
|
-
}
|
|
109
|
-
async function resend() {
|
|
110
|
-
setPending("resend");
|
|
111
|
-
setError(null);
|
|
112
|
-
setNotice(null);
|
|
113
|
-
if (await sendCode(email)) {
|
|
114
|
-
setCode("");
|
|
115
|
-
setNotice(`We sent a new code to ${email}.`);
|
|
116
|
-
}
|
|
117
|
-
setPending(null);
|
|
118
|
-
}
|
|
119
|
-
function changeEmail() {
|
|
120
|
-
setStep("email");
|
|
121
|
-
setCode("");
|
|
122
|
-
setError(null);
|
|
123
|
-
setNotice(null);
|
|
124
|
-
}
|
|
125
|
-
async function switchAccount() {
|
|
126
|
-
setPending("sign-out");
|
|
127
|
-
setError(null);
|
|
128
|
-
await attempt(() => authClient().signOut());
|
|
129
|
-
clearSession();
|
|
130
|
-
await refreshSession();
|
|
131
|
-
setSwitchingAccount(true);
|
|
132
|
-
setStep("email");
|
|
133
|
-
setPending(null);
|
|
134
|
-
}
|
|
18
|
+
}, [step, pending]);
|
|
135
19
|
const errorId = `${ids}-error`;
|
|
136
|
-
const sessionEmail = session.status === "signed-in" ? (session.user.email ?? session.user.name) : null;
|
|
137
20
|
if (notInvited) {
|
|
138
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" })] }));
|
|
139
22
|
}
|
|
140
|
-
if (
|
|
23
|
+
if (signedIn && step === "email" && pending === null) {
|
|
141
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" })] })] }));
|
|
142
25
|
}
|
|
143
26
|
if (step === "email") {
|
|
144
|
-
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 ",
|
|
27
|
+
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }), _jsxs("p", { className: "ss-text", children: ["We will email you a ", codeLength, "-digit code to sign in."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
|
|
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 code…" : "Send code" })] })] }));
|
|
145
31
|
}
|
|
146
|
-
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 ",
|
|
32
|
+
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) => {
|
|
147
33
|
event.preventDefault();
|
|
148
|
-
void
|
|
149
|
-
}, 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:
|
|
150
|
-
const digits = event.target.value.replace(/\D/g, "").slice(0,
|
|
34
|
+
void submitCode();
|
|
35
|
+
}, 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) => {
|
|
36
|
+
const digits = event.target.value.replace(/\D/g, "").slice(0, codeLength);
|
|
151
37
|
setCode(digits);
|
|
152
|
-
if (digits.length ===
|
|
153
|
-
void
|
|
38
|
+
if (digits.length === codeLength) {
|
|
39
|
+
void submitCode(digits);
|
|
154
40
|
}
|
|
155
41
|
}, disabled: pending === "verify", "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), notice !== null && (_jsx("p", { className: "ss-notice", role: "status", children: notice })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "verify", children: pending === "verify" ? "Signing in…" : "Sign in" })] }), _jsxs("div", { className: "ss-actions", children: [_jsx("button", { type: "button", className: "ss-link", onClick: () => void resend(), disabled: pending !== null || resendIn > 0, "aria-busy": pending === "resend", children: pending === "resend"
|
|
156
42
|
? "Sending…"
|
|
@@ -158,41 +44,3 @@ export function SignIn({ returnTo, error: reason, title = "Sign in" }) {
|
|
|
158
44
|
? `Resend code in ${resendIn} s`
|
|
159
45
|
: "Resend code" }), _jsx("button", { type: "button", className: "ss-link", onClick: changeEmail, disabled: pending !== null, children: "Use another email" })] })] }));
|
|
160
46
|
}
|
|
161
|
-
/** Runs a Neon Auth client call; its failure, returned or thrown, or `null` on success. */
|
|
162
|
-
async function attempt(call) {
|
|
163
|
-
try {
|
|
164
|
-
const result = await call();
|
|
165
|
-
return result.error ?? null;
|
|
166
|
-
}
|
|
167
|
-
catch (error) {
|
|
168
|
-
return error ?? new Error("Sign-in failed.");
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
/** A sentence for a Neon Auth (Better Auth) failure. */
|
|
172
|
-
export function signInErrorMessage(error) {
|
|
173
|
-
const details = typeof error === "object" && error !== null ? error : {};
|
|
174
|
-
const status = typeof details.status === "number" ? details.status : undefined;
|
|
175
|
-
const text = [details.code, details.message]
|
|
176
|
-
.filter((part) => typeof part === "string")
|
|
177
|
-
.join(" ")
|
|
178
|
-
.toLowerCase();
|
|
179
|
-
if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
|
|
180
|
-
return "Too many requests. Wait a minute, then try again.";
|
|
181
|
-
}
|
|
182
|
-
if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
|
|
183
|
-
return "Too many wrong codes. Send a new code.";
|
|
184
|
-
}
|
|
185
|
-
if (text.includes("expired")) {
|
|
186
|
-
return "That code has expired. Send a new one.";
|
|
187
|
-
}
|
|
188
|
-
if (text.includes("otp") || text.includes("code")) {
|
|
189
|
-
return "That code is not right. Check the email and try again.";
|
|
190
|
-
}
|
|
191
|
-
if (text.includes("email")) {
|
|
192
|
-
return "Enter a valid email address.";
|
|
193
|
-
}
|
|
194
|
-
if (status === undefined || status >= 500 || text.includes("network")) {
|
|
195
|
-
return "Sign-in is unavailable right now. Try again in a moment.";
|
|
196
|
-
}
|
|
197
|
-
return "Sign-in failed. Try again.";
|
|
198
|
-
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
/** Email code sign-in for a form the app renders itself. The calls take values, not form events. */
|
|
12
|
+
export interface SignInController {
|
|
13
|
+
readonly step: "email" | "code";
|
|
14
|
+
readonly email: string;
|
|
15
|
+
readonly setEmail: (value: string) => void;
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly setCode: (value: string) => void;
|
|
18
|
+
/** The call in flight; disable the form while it is not `null`. */
|
|
19
|
+
readonly pending: "send" | "verify" | "resend" | "sign-out" | null;
|
|
20
|
+
/** A sentence for the person, from the last failed call. */
|
|
21
|
+
readonly error: string | null;
|
|
22
|
+
/** A confirmation, such as a new code having been sent. */
|
|
23
|
+
readonly notice: string | null;
|
|
24
|
+
/** Seconds until another code may be requested; 0 when it may. */
|
|
25
|
+
readonly resendIn: number;
|
|
26
|
+
/** Digits in a code: 6. */
|
|
27
|
+
readonly codeLength: number;
|
|
28
|
+
/** The same-origin path the person returns to after signing in. */
|
|
29
|
+
readonly destination: string;
|
|
30
|
+
/**
|
|
31
|
+
* A session exists in this browser and the person has not chosen another account: offer to
|
|
32
|
+
* continue to `destination` instead of the email step.
|
|
33
|
+
*/
|
|
34
|
+
readonly signedIn: boolean;
|
|
35
|
+
/** The page was opened for a signed-in person the app has not invited. */
|
|
36
|
+
readonly notInvited: boolean;
|
|
37
|
+
/** The emailed sign-in link was expired, used or unknown: say so on the email step. */
|
|
38
|
+
readonly linkInvalid: boolean;
|
|
39
|
+
/** The signed-in address or name, for the not-invited state; null when signed out. */
|
|
40
|
+
readonly sessionEmail: string | null;
|
|
41
|
+
/** Sends a code to `email` and moves to the code step. Call it from the form's submit handler. */
|
|
42
|
+
readonly submitEmail: () => Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Verifies `value`, or the current `code`, and opens `destination`. A wrong code clears `code`;
|
|
45
|
+
* a second call while one is verifying is ignored, so the last digit and Enter may both submit.
|
|
46
|
+
*/
|
|
47
|
+
readonly submitCode: (value?: string) => Promise<void>;
|
|
48
|
+
/** Sends another code to the same address. */
|
|
49
|
+
readonly resend: () => Promise<void>;
|
|
50
|
+
/** Returns to the email step. */
|
|
51
|
+
readonly changeEmail: () => void;
|
|
52
|
+
/** Signs the current session out and returns to the email step. */
|
|
53
|
+
readonly switchAccount: () => Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Email code sign-in with Neon Auth, without markup: the email address, then the six-digit code
|
|
57
|
+
* sent to it. New addresses get an account on first sign-in; whether they may use the app is the
|
|
58
|
+
* ontology's decision, which `requireUser()` reports as `not_invited`. `<SignIn />` renders it with
|
|
59
|
+
* the SDK's own styles; apps with their own design system render the controller themselves.
|
|
60
|
+
*/
|
|
61
|
+
export declare function useSignIn({ returnTo, error: reason }?: SignInOptions): SignInController;
|
|
62
|
+
/** A sentence for a Neon Auth (Better Auth) failure. */
|
|
63
|
+
export declare function signInErrorMessage(error: unknown): string;
|
|
@@ -0,0 +1,186 @@
|
|
|
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 { authClient, clearSession, refreshSession } from "./store.js";
|
|
8
|
+
/** Seconds before another code may be requested. */
|
|
9
|
+
const RESEND_AFTER_SECONDS = 30;
|
|
10
|
+
const CODE_LENGTH = 6;
|
|
11
|
+
const noSubscription = () => () => { };
|
|
12
|
+
/** The page's own query string; empty during server rendering. */
|
|
13
|
+
function useSearchParameter(name) {
|
|
14
|
+
const search = useSyncExternalStore(noSubscription, () => window.location.search, () => "");
|
|
15
|
+
return new URLSearchParams(search).get(name);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Email code sign-in with Neon Auth, without markup: the email address, then the six-digit code
|
|
19
|
+
* sent to it. New addresses get an account on first sign-in; whether they may use the app is the
|
|
20
|
+
* ontology's decision, which `requireUser()` reports as `not_invited`. `<SignIn />` renders it with
|
|
21
|
+
* the SDK's own styles; apps with their own design system render the controller themselves.
|
|
22
|
+
*/
|
|
23
|
+
export function useSignIn({ returnTo, error: reason } = {}) {
|
|
24
|
+
const session = useSession();
|
|
25
|
+
const pageReturnTo = useSearchParameter("returnTo");
|
|
26
|
+
const pageError = useSearchParameter("error");
|
|
27
|
+
const [step, setStep] = useState("email");
|
|
28
|
+
const [email, setEmail] = useState("");
|
|
29
|
+
const [code, setCode] = useState("");
|
|
30
|
+
const [pending, setPending] = useState(null);
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const [notice, setNotice] = useState(null);
|
|
33
|
+
const [resendIn, setResendIn] = useState(0);
|
|
34
|
+
const [switchingAccount, setSwitchingAccount] = useState(false);
|
|
35
|
+
const verifying = useRef(false);
|
|
36
|
+
const resendTimer = useRef(undefined);
|
|
37
|
+
const destination = safeReturnTo(returnTo ?? pageReturnTo);
|
|
38
|
+
const why = reason ?? pageError;
|
|
39
|
+
useEffect(() => () => clearInterval(resendTimer.current), []);
|
|
40
|
+
function holdResend() {
|
|
41
|
+
const deadline = Date.now() + RESEND_AFTER_SECONDS * 1000;
|
|
42
|
+
clearInterval(resendTimer.current);
|
|
43
|
+
setResendIn(RESEND_AFTER_SECONDS);
|
|
44
|
+
resendTimer.current = setInterval(() => {
|
|
45
|
+
const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
46
|
+
setResendIn(remaining);
|
|
47
|
+
if (remaining === 0) {
|
|
48
|
+
clearInterval(resendTimer.current);
|
|
49
|
+
}
|
|
50
|
+
}, 1000);
|
|
51
|
+
}
|
|
52
|
+
async function sendCode(address) {
|
|
53
|
+
const result = await attempt(() => authClient().emailOtp.sendVerificationOtp({ email: address, type: "sign-in" }));
|
|
54
|
+
if (result !== null) {
|
|
55
|
+
setError(signInErrorMessage(result));
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
holdResend();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
async function submitEmail() {
|
|
62
|
+
const address = email.trim();
|
|
63
|
+
if (address === "") {
|
|
64
|
+
setError("Enter your email address.");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setPending("send");
|
|
68
|
+
setError(null);
|
|
69
|
+
setNotice(null);
|
|
70
|
+
if (await sendCode(address)) {
|
|
71
|
+
setEmail(address);
|
|
72
|
+
setCode("");
|
|
73
|
+
setStep("code");
|
|
74
|
+
}
|
|
75
|
+
setPending(null);
|
|
76
|
+
}
|
|
77
|
+
async function submitCode(value = code) {
|
|
78
|
+
// The last digit and the Enter key may both submit the same code.
|
|
79
|
+
if (verifying.current) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!new RegExp(`^[0-9]{${CODE_LENGTH}}$`).test(value)) {
|
|
83
|
+
setError(`Enter the ${CODE_LENGTH}-digit code from the email.`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
verifying.current = true;
|
|
87
|
+
setPending("verify");
|
|
88
|
+
setError(null);
|
|
89
|
+
setNotice(null);
|
|
90
|
+
const result = await attempt(() => authClient().signIn.emailOtp({ email, otp: value }));
|
|
91
|
+
if (result !== null) {
|
|
92
|
+
verifying.current = false;
|
|
93
|
+
setError(signInErrorMessage(result));
|
|
94
|
+
setCode("");
|
|
95
|
+
setPending(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
navigate(destination);
|
|
99
|
+
}
|
|
100
|
+
async function resend() {
|
|
101
|
+
setPending("resend");
|
|
102
|
+
setError(null);
|
|
103
|
+
setNotice(null);
|
|
104
|
+
if (await sendCode(email)) {
|
|
105
|
+
setCode("");
|
|
106
|
+
setNotice(`We sent a new code to ${email}.`);
|
|
107
|
+
}
|
|
108
|
+
setPending(null);
|
|
109
|
+
}
|
|
110
|
+
function changeEmail() {
|
|
111
|
+
setStep("email");
|
|
112
|
+
setCode("");
|
|
113
|
+
setError(null);
|
|
114
|
+
setNotice(null);
|
|
115
|
+
}
|
|
116
|
+
async function switchAccount() {
|
|
117
|
+
setPending("sign-out");
|
|
118
|
+
setError(null);
|
|
119
|
+
await attempt(() => authClient().signOut());
|
|
120
|
+
clearSession();
|
|
121
|
+
await refreshSession();
|
|
122
|
+
setSwitchingAccount(true);
|
|
123
|
+
setStep("email");
|
|
124
|
+
setPending(null);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
step,
|
|
128
|
+
email,
|
|
129
|
+
setEmail,
|
|
130
|
+
code,
|
|
131
|
+
setCode,
|
|
132
|
+
pending,
|
|
133
|
+
error,
|
|
134
|
+
notice,
|
|
135
|
+
resendIn,
|
|
136
|
+
codeLength: CODE_LENGTH,
|
|
137
|
+
destination,
|
|
138
|
+
signedIn: session.status === "signed-in" && !switchingAccount,
|
|
139
|
+
notInvited: why === NOT_INVITED && !switchingAccount,
|
|
140
|
+
linkInvalid: why === SIGN_IN_LINK_INVALID,
|
|
141
|
+
sessionEmail: session.status === "signed-in" ? (session.user.email ?? session.user.name) : null,
|
|
142
|
+
submitEmail,
|
|
143
|
+
submitCode,
|
|
144
|
+
resend,
|
|
145
|
+
changeEmail,
|
|
146
|
+
switchAccount,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Runs a Neon Auth client call; its failure, returned or thrown, or `null` on success. */
|
|
150
|
+
async function attempt(call) {
|
|
151
|
+
try {
|
|
152
|
+
const result = await call();
|
|
153
|
+
return result.error ?? null;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return error ?? new Error("Sign-in failed.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** A sentence for a Neon Auth (Better Auth) failure. */
|
|
160
|
+
export function signInErrorMessage(error) {
|
|
161
|
+
const details = typeof error === "object" && error !== null ? error : {};
|
|
162
|
+
const status = typeof details.status === "number" ? details.status : undefined;
|
|
163
|
+
const text = [details.code, details.message]
|
|
164
|
+
.filter((part) => typeof part === "string")
|
|
165
|
+
.join(" ")
|
|
166
|
+
.toLowerCase();
|
|
167
|
+
if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
|
|
168
|
+
return "Too many requests. Wait a minute, then try again.";
|
|
169
|
+
}
|
|
170
|
+
if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
|
|
171
|
+
return "Too many wrong codes. Send a new code.";
|
|
172
|
+
}
|
|
173
|
+
if (text.includes("expired")) {
|
|
174
|
+
return "That code has expired. Send a new one.";
|
|
175
|
+
}
|
|
176
|
+
if (text.includes("otp") || text.includes("code")) {
|
|
177
|
+
return "That code is not right. Check the email and try again.";
|
|
178
|
+
}
|
|
179
|
+
if (text.includes("email")) {
|
|
180
|
+
return "Enter a valid email address.";
|
|
181
|
+
}
|
|
182
|
+
if (status === undefined || status >= 500 || text.includes("network")) {
|
|
183
|
+
return "Sign-in is unavailable right now. Try again in a moment.";
|
|
184
|
+
}
|
|
185
|
+
return "Sign-in failed. Try again.";
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/app",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|