@segmentstream-cloud/app 0.3.0 → 0.3.1
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 +6 -0
- 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 +1 -1
- package/dist/ui/index.js +1 -1
- package/dist/ui/sign-in.d.ts +2 -1
- package/dist/ui/sign-in.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,12 @@ 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 />`
|
|
60
|
+
offers a new code.
|
|
61
|
+
|
|
56
62
|
**`src/app/sign-in/page.tsx`** renders the sign-in form, and the root layout imports
|
|
57
63
|
its stylesheet once:
|
|
58
64
|
|
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
|
@@ -7,4 +7,4 @@ export type { SignInProps } from "./sign-in.js";
|
|
|
7
7
|
export type { SessionState, SessionUser, UserState } from "./store.js";
|
|
8
8
|
export { isAllowed } from "../permissions.js";
|
|
9
9
|
export type { CurrentUser } from "../me.js";
|
|
10
|
-
export { SegmentStreamApiError, NOT_INVITED } from "../errors.js";
|
|
10
|
+
export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
|
package/dist/ui/index.js
CHANGED
|
@@ -5,4 +5,4 @@ export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
|
|
|
5
5
|
export { usePermissions, useSession, useUser } from "./hooks.js";
|
|
6
6
|
export { SignIn, signInErrorMessage } from "./sign-in.js";
|
|
7
7
|
export { isAllowed } from "../permissions.js";
|
|
8
|
-
export { SegmentStreamApiError, NOT_INVITED } from "../errors.js";
|
|
8
|
+
export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
|
package/dist/ui/sign-in.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export interface SignInProps {
|
|
|
2
2
|
/** Where to go once signed in. Defaults to the page's `returnTo` parameter, then `/`. */
|
|
3
3
|
readonly returnTo?: string;
|
|
4
4
|
/**
|
|
5
|
-
* Why the page was opened: `not_invited` when `requireUser()` found no invitation
|
|
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
|
|
6
7
|
* the page's `error` parameter.
|
|
7
8
|
*/
|
|
8
9
|
readonly error?: string;
|
package/dist/ui/sign-in.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { SIGN_IN_LINK_INVALID } from "../errors.js";
|
|
4
5
|
import { safeReturnTo } from "../return-to.js";
|
|
5
6
|
import { useSession } from "./hooks.js";
|
|
6
7
|
import { navigate } from "./navigation.js";
|
|
@@ -38,6 +39,7 @@ export function SignIn({ returnTo, error: reason, title = "Sign in" }) {
|
|
|
38
39
|
const resendTimer = useRef(undefined);
|
|
39
40
|
const destination = safeReturnTo(returnTo ?? pageReturnTo);
|
|
40
41
|
const notInvited = (reason ?? pageError) === NOT_INVITED && !switchingAccount;
|
|
42
|
+
const linkInvalid = (reason ?? pageError) === SIGN_IN_LINK_INVALID;
|
|
41
43
|
useEffect(() => () => clearInterval(resendTimer.current), []);
|
|
42
44
|
useEffect(() => {
|
|
43
45
|
if (step === "code") {
|
|
@@ -141,7 +143,7 @@ export function SignIn({ returnTo, error: reason, title = "Sign in" }) {
|
|
|
141
143
|
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
144
|
}
|
|
143
145
|
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 ", CODE_LENGTH, "-digit code to sign in."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => void submitEmail(event), noValidate: true, children: [_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" })] })] }));
|
|
146
|
+
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }), _jsxs("p", { className: "ss-text", children: ["We will email you a ", CODE_LENGTH, "-digit code to sign in."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => void submitEmail(event), noValidate: true, children: [linkInvalid && (_jsx("p", { className: "ss-notice", role: "status", children: "This sign-in link is not valid anymore. Enter your email to get a new code." })), _jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-email`, children: "Email" }), _jsx("input", { id: `${ids}-email`, className: "ss-input", type: "email", name: "email", autoComplete: "email", inputMode: "email", required: true, value: email, onChange: (event) => setEmail(event.target.value), disabled: pending !== null, "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "send", children: pending === "send" ? "Sending code…" : "Send code" })] })] }));
|
|
145
147
|
}
|
|
146
148
|
return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "Check your email" }), _jsxs("p", { className: "ss-text", children: ["Enter the ", CODE_LENGTH, "-digit code we sent to ", _jsx("strong", { children: email }), "."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
|
|
147
149
|
event.preventDefault();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/app",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|