@segmentstream-cloud/app 0.1.0 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +230 -47
  2. package/dist/app.d.ts +42 -5
  3. package/dist/app.js +81 -7
  4. package/dist/config.d.ts +17 -1
  5. package/dist/config.js +29 -7
  6. package/dist/cookies.d.ts +16 -7
  7. package/dist/cookies.js +36 -47
  8. package/dist/data.d.ts +126 -0
  9. package/dist/data.js +206 -0
  10. package/dist/errors.d.ts +36 -0
  11. package/dist/errors.js +66 -0
  12. package/dist/handlers.js +43 -19
  13. package/dist/index.d.ts +21 -7
  14. package/dist/index.js +12 -5
  15. package/dist/me.d.ts +24 -0
  16. package/dist/me.js +33 -0
  17. package/dist/neon.d.ts +92 -0
  18. package/dist/neon.js +414 -0
  19. package/dist/ontology-transport.d.ts +23 -0
  20. package/dist/ontology-transport.js +95 -0
  21. package/dist/ontology.d.ts +217 -0
  22. package/dist/ontology.js +141 -0
  23. package/dist/parameters.d.ts +55 -0
  24. package/dist/parameters.js +118 -0
  25. package/dist/permissions.d.ts +17 -0
  26. package/dist/permissions.js +33 -0
  27. package/dist/proxy.d.ts +2 -0
  28. package/dist/proxy.js +29 -13
  29. package/dist/return-to.d.ts +2 -0
  30. package/dist/return-to.js +24 -0
  31. package/dist/server.d.ts +3 -0
  32. package/dist/server.js +35 -5
  33. package/dist/sign-in.d.ts +5 -1
  34. package/dist/sign-in.js +17 -4
  35. package/dist/tokens.d.ts +22 -3
  36. package/dist/ui/components.d.ts +27 -0
  37. package/dist/ui/components.js +63 -0
  38. package/dist/ui/hooks.d.ts +19 -0
  39. package/dist/ui/hooks.js +56 -0
  40. package/dist/ui/index.d.ts +10 -0
  41. package/dist/ui/index.js +8 -0
  42. package/dist/ui/navigation.d.ts +5 -0
  43. package/dist/ui/navigation.js +8 -0
  44. package/dist/ui/sign-in.d.ts +19 -0
  45. package/dist/ui/sign-in.js +198 -0
  46. package/dist/ui/store.d.ts +61 -0
  47. package/dist/ui/store.js +155 -0
  48. package/dist/ui/styles.css +249 -0
  49. package/package.json +24 -7
package/dist/proxy.d.ts CHANGED
@@ -11,3 +11,5 @@ export interface ProxyOptions {
11
11
  }
12
12
  export type SegmentStreamProxy = (request: NextRequest) => Promise<NextResponse>;
13
13
  export declare function createProxy(context: AppContext, options?: ProxyOptions): SegmentStreamProxy;
14
+ export declare function normalizePublicPath(path: string): string;
15
+ export declare function matchesPathPrefix(pathname: string, prefix: string): boolean;
package/dist/proxy.js CHANGED
@@ -4,13 +4,29 @@ import { clearSessionCookies, cookiePolicyFor, replaceRequestCookies, safeReturn
4
4
  import { SDK_ROUTE_PREFIX, isApiRequest, signInRedirect, unauthenticatedResponse, } from "./sign-in.js";
5
5
  export function createProxy(context, options = {}) {
6
6
  const publicPaths = (options.publicPaths ?? []).map(normalizePublicPath);
7
+ const refreshes = new Map();
8
+ /**
9
+ * A page load and the prefetches it triggers arrive together with the same expired
10
+ * session. They share one refresh instead of each spending the refresh token.
11
+ */
12
+ function refreshSession(refreshToken) {
13
+ const pending = refreshes.get(refreshToken);
14
+ if (pending !== undefined) {
15
+ return pending;
16
+ }
17
+ const exchange = context
18
+ .requestToken({ grantType: "refresh_token", refreshToken })
19
+ .finally(() => refreshes.delete(refreshToken));
20
+ refreshes.set(refreshToken, exchange);
21
+ return exchange;
22
+ }
7
23
  return async function proxy(request) {
8
24
  const pathname = request.nextUrl.pathname;
9
25
  if (matchesPathPrefix(pathname, SDK_ROUTE_PREFIX) ||
10
26
  publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
11
27
  return NextResponse.next();
12
28
  }
13
- const policy = cookiePolicyFor(request.nextUrl);
29
+ const policy = cookiePolicyFor(request.nextUrl, context.config.workspaceId);
14
30
  const accessToken = request.cookies.get(policy.names.session)?.value;
15
31
  if (accessToken !== undefined && accessToken !== "") {
16
32
  const verification = await context.verifyToken(accessToken);
@@ -20,14 +36,14 @@ export function createProxy(context, options = {}) {
20
36
  }
21
37
  const refreshToken = request.cookies.get(policy.names.refresh)?.value;
22
38
  if (refreshToken === undefined || refreshToken === "") {
23
- return startSignIn(context, request, accessToken !== undefined);
39
+ return startSignIn(context, request, policy, accessToken !== undefined);
24
40
  }
25
- const exchange = await context.requestToken({ grantType: "refresh_token", refreshToken });
41
+ const exchange = await refreshSession(refreshToken);
26
42
  if (exchange.status === "rejected") {
27
- return startSignIn(context, request, true);
43
+ return startSignIn(context, request, policy, true);
28
44
  }
29
45
  if (exchange.status === "unavailable") {
30
- return sessionUnavailable(context, request);
46
+ return sessionUnavailable(context, request, policy);
31
47
  }
32
48
  const verification = await context.verifyToken(exchange.tokens.accessToken);
33
49
  if (verification.status === "valid") {
@@ -35,28 +51,28 @@ export function createProxy(context, options = {}) {
35
51
  }
36
52
  if (verification.status === "unavailable") {
37
53
  // The refresh token was rotated; keep the new one even though this request cannot proceed.
38
- const response = await sessionUnavailable(context, request);
54
+ const response = await sessionUnavailable(context, request, policy);
39
55
  setSessionCookies(response, policy, exchange.tokens);
40
56
  return response;
41
57
  }
42
- return startSignIn(context, request, true);
58
+ return startSignIn(context, request, policy, true);
43
59
  };
44
60
  }
45
61
  /**
46
62
  * SegmentStream is unreachable. API requests get a retryable 503; pages restart sign-in,
47
63
  * which ends on the callback's error page rather than looping. Session cookies are kept.
48
64
  */
49
- async function sessionUnavailable(context, request) {
65
+ async function sessionUnavailable(context, request, policy) {
50
66
  if (isApiRequest(request)) {
51
67
  return NextResponse.json({ error: "session_refresh_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
52
68
  }
53
- return startSignIn(context, request, false);
69
+ return startSignIn(context, request, policy, false);
54
70
  }
55
- async function startSignIn(context, request, clearSession) {
71
+ async function startSignIn(context, request, policy, clearSession) {
56
72
  if (isApiRequest(request)) {
57
73
  const response = unauthenticatedResponse();
58
74
  if (clearSession) {
59
- clearSessionCookies(response, cookiePolicyFor(request.nextUrl));
75
+ clearSessionCookies(response, policy);
60
76
  }
61
77
  return response;
62
78
  }
@@ -78,12 +94,12 @@ function continueWithSession(request, policy, tokens) {
78
94
  setSessionCookies(response, policy, tokens);
79
95
  return response;
80
96
  }
81
- function normalizePublicPath(path) {
97
+ export function normalizePublicPath(path) {
82
98
  const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
83
99
  const trimmed = trimTrailingSlashes(withLeadingSlash);
84
100
  return trimmed === "" ? "/" : trimmed;
85
101
  }
86
- function matchesPathPrefix(pathname, prefix) {
102
+ export function matchesPathPrefix(pathname, prefix) {
87
103
  if (prefix === "/") {
88
104
  return true;
89
105
  }
@@ -0,0 +1,2 @@
1
+ /** Reduces a return path to a same-origin relative path, falling back to `/`. */
2
+ export declare function safeReturnTo(value: string | null | undefined): string;
@@ -0,0 +1,24 @@
1
+ /** Reduces a return path to a same-origin relative path, falling back to `/`. */
2
+ export function safeReturnTo(value) {
3
+ if (typeof value !== "string" ||
4
+ !value.startsWith("/") ||
5
+ value.startsWith("//") ||
6
+ value.startsWith("/\\")) {
7
+ return "/";
8
+ }
9
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
10
+ return "/";
11
+ }
12
+ const placeholderOrigin = "https://return-to.invalid";
13
+ let url;
14
+ try {
15
+ url = new URL(value, placeholderOrigin);
16
+ }
17
+ catch {
18
+ return "/";
19
+ }
20
+ if (url.origin !== placeholderOrigin) {
21
+ return "/";
22
+ }
23
+ return `${url.pathname}${url.search}${url.hash}`;
24
+ }
package/dist/server.d.ts CHANGED
@@ -4,5 +4,8 @@ export interface ServerSession {
4
4
  readonly getUser: () => Promise<AppUser | null>;
5
5
  readonly requireUser: () => Promise<AppUser>;
6
6
  readonly getAccessToken: () => Promise<string | null>;
7
+ readonly signOut: (options?: {
8
+ readonly redirectTo?: string;
9
+ }) => Promise<never>;
7
10
  }
8
11
  export declare function createServerSession(context: AppContext): ServerSession;
package/dist/server.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { cookies, headers } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
- import { sessionCookieCandidates } from "./cookies.js";
4
- import { SIGN_IN_PATH } from "./sign-in.js";
3
+ import { cookiePolicyFor, isLoopbackHostname, safeReturnTo, sessionCookieCandidates, } from "./cookies.js";
4
+ import { SIGNED_OUT_PATH, SIGN_IN_PATH } from "./sign-in.js";
5
5
  export function createServerSession(context) {
6
6
  async function verifiedSession() {
7
7
  const cookieStore = await cookies();
8
8
  const headerStore = await headers();
9
9
  const hostname = hostnameOf(headerStore.get("host"));
10
- for (const name of sessionCookieCandidates(hostname)) {
10
+ for (const name of sessionCookieCandidates(hostname, context.config.workspaceId)) {
11
11
  const token = cookieStore.get(name)?.value;
12
12
  if (token === undefined || token === "") {
13
13
  continue;
@@ -21,7 +21,8 @@ export function createServerSession(context) {
21
21
  }
22
22
  async function getUser() {
23
23
  const session = await verifiedSession();
24
- return session?.user ?? null;
24
+ // Ontology roles come with Neon Auth; the legacy sign-in has none.
25
+ return session === null ? null : { ...session.user, roles: [], permissions: [] };
25
26
  }
26
27
  async function requireUser() {
27
28
  const user = await getUser();
@@ -35,7 +36,36 @@ export function createServerSession(context) {
35
36
  const session = await verifiedSession();
36
37
  return session?.token ?? null;
37
38
  }
38
- return { getUser, requireUser, getAccessToken };
39
+ /** Revokes the app session and clears its cookies, as `POST /api/segmentstream/sign-out` does. */
40
+ async function signOut(options = {}) {
41
+ const cookieStore = await cookies();
42
+ const hostname = hostnameOf((await headers()).get("host"));
43
+ const policies = [
44
+ cookiePolicyFor(new URL("https://app.invalid"), context.config.workspaceId),
45
+ ...(isLoopbackHostname(hostname)
46
+ ? [cookiePolicyFor(new URL("http://localhost"), context.config.workspaceId)]
47
+ : []),
48
+ ];
49
+ for (const policy of policies) {
50
+ const refreshToken = cookieStore.get(policy.names.refresh)?.value;
51
+ if (refreshToken !== undefined && refreshToken !== "") {
52
+ await context.revokeSession(refreshToken);
53
+ }
54
+ for (const name of [policy.names.session, policy.names.refresh]) {
55
+ // `__Host-` cookies are only replaced when the clearing cookie repeats Secure and Path=/.
56
+ cookieStore.set(name, "", {
57
+ httpOnly: true,
58
+ secure: policy.secure,
59
+ sameSite: "lax",
60
+ path: "/",
61
+ maxAge: 0,
62
+ expires: new Date(0),
63
+ });
64
+ }
65
+ }
66
+ redirect(safeReturnTo(options.redirectTo ?? SIGNED_OUT_PATH));
67
+ }
68
+ return { getUser, requireUser, getAccessToken, signOut };
39
69
  }
40
70
  function hostnameOf(host) {
41
71
  if (host === null || host === "") {
package/dist/sign-in.d.ts CHANGED
@@ -17,7 +17,11 @@ export interface SignInRedirectInput {
17
17
  readonly returnTo: string;
18
18
  readonly clearSession: boolean;
19
19
  }
20
- /** Starts the authorization-code flow with PKCE by redirecting to SegmentStream. */
20
+ /**
21
+ * Starts the authorization-code flow with PKCE by redirecting to SegmentStream. Each
22
+ * sign-in keeps its flow in a cookie named after its `state`, so tabs that start
23
+ * signing in at the same time do not overwrite each other.
24
+ */
21
25
  export declare function signInRedirect(input: SignInRedirectInput): Promise<NextResponse>;
22
26
  export interface AuthorizationUrlInput {
23
27
  readonly config: ResolvedConfig;
package/dist/sign-in.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { NextResponse } from "next/server";
2
- import { clearSessionCookies, cookiePolicyFor, setAuthFlowCookie } from "./cookies.js";
2
+ import { MAX_PENDING_AUTH_FLOWS, clearCookie, clearSessionCookies, cookiePolicyFor, pendingAuthFlowCookies, setAuthFlowCookie, } from "./cookies.js";
3
3
  import { createCodeChallenge, randomToken } from "./pkce.js";
4
4
  export const SDK_ROUTE_PREFIX = "/api/segmentstream";
5
5
  export const CALLBACK_PATH = `${SDK_ROUTE_PREFIX}/callback`;
6
6
  export const SIGN_IN_PATH = `${SDK_ROUTE_PREFIX}/sign-in`;
7
7
  export const SIGNED_OUT_PATH = `${SDK_ROUTE_PREFIX}/signed-out`;
8
+ /** Longer return paths are dropped so several pending sign-ins still fit in request headers. */
9
+ const MAX_RETURN_TO_LENGTH = 1024;
8
10
  /**
9
11
  * Requests that cannot follow a cross-origin sign-in redirect: anything other than a
10
12
  * top-level page navigation. They receive a 401 instead of a redirect.
@@ -27,17 +29,28 @@ export function isApiRequest(request) {
27
29
  export function unauthenticatedResponse() {
28
30
  return NextResponse.json({ error: "unauthenticated" }, { status: 401, headers: { "cache-control": "no-store" } });
29
31
  }
30
- /** Starts the authorization-code flow with PKCE by redirecting to SegmentStream. */
32
+ /**
33
+ * Starts the authorization-code flow with PKCE by redirecting to SegmentStream. Each
34
+ * sign-in keeps its flow in a cookie named after its `state`, so tabs that start
35
+ * signing in at the same time do not overwrite each other.
36
+ */
31
37
  export async function signInRedirect(input) {
32
- const { config, request, returnTo, clearSession } = input;
33
- const policy = cookiePolicyFor(request.nextUrl);
38
+ const { config, request, clearSession } = input;
39
+ const policy = cookiePolicyFor(request.nextUrl, config.workspaceId);
34
40
  const state = randomToken();
35
41
  const codeVerifier = randomToken();
36
42
  const codeChallenge = await createCodeChallenge(codeVerifier);
37
43
  const redirectUri = `${request.nextUrl.origin}${CALLBACK_PATH}`;
44
+ const returnTo = input.returnTo.length <= MAX_RETURN_TO_LENGTH ? input.returnTo : "/";
38
45
  const authorizeUrl = authorizationUrl({ config, redirectUri, state, codeChallenge });
39
46
  const response = NextResponse.redirect(authorizeUrl, 307);
47
+ const pending = pendingAuthFlowCookies(policy, request.cookies.getAll().map((cookie) => cookie.name));
40
48
  response.headers.set("cache-control", "no-store");
49
+ if (pending.length >= MAX_PENDING_AUTH_FLOWS) {
50
+ for (const name of pending) {
51
+ clearCookie(response, policy, name);
52
+ }
53
+ }
41
54
  setAuthFlowCookie(response, policy, { state, codeVerifier, redirectUri, returnTo });
42
55
  if (clearSession) {
43
56
  clearSessionCookies(response, policy);
package/dist/tokens.d.ts CHANGED
@@ -1,17 +1,36 @@
1
1
  import type { JWTVerifyGetKey } from "jose";
2
2
  import type { ResolvedConfig } from "./config.js";
3
- /** The signed-in SegmentStream user, as asserted by the app session token. */
4
- export interface AppUser {
3
+ /** The SegmentStream member a legacy app session token names. */
4
+ export interface TokenUser {
5
5
  readonly id: string;
6
6
  readonly email?: string;
7
7
  readonly name?: string;
8
8
  readonly organizationId: string;
9
9
  readonly workspaceId: string;
10
10
  }
11
+ /**
12
+ * The signed-in user. With Neon Auth, the person's ontology `user` record with the roles and
13
+ * permissions from `GET /api/ontology/me`; with the legacy SegmentStream sign-in, the member the app
14
+ * session token names, without ontology roles.
15
+ */
16
+ export interface AppUser extends TokenUser {
17
+ /** Neon Auth: the ontology `user` record's id. Legacy sign-in: the SegmentStream user id. */
18
+ readonly id: string;
19
+ /** The ontology roles the user holds, such as `sales`. Empty under the legacy sign-in. */
20
+ readonly roles: ReadonlyArray<string>;
21
+ /** The permissions the roles grant, wildcards included. Empty under the legacy sign-in. */
22
+ readonly permissions: ReadonlyArray<string>;
23
+ /** Neon Auth: the ontology user's kind, `member` for people. */
24
+ readonly kind?: string;
25
+ /** Neon Auth: whether the ontology user is active. */
26
+ readonly active?: boolean;
27
+ /** Neon Auth: the person's Neon Auth user id, the session JWT's `sub`. */
28
+ readonly authUserId?: string;
29
+ }
11
30
  export type KeySet = JWTVerifyGetKey;
12
31
  export interface VerifiedToken {
13
32
  readonly status: "valid";
14
- readonly user: AppUser;
33
+ readonly user: TokenUser;
15
34
  readonly token: string;
16
35
  }
17
36
  export type TokenVerification = VerifiedToken | {
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from "react";
2
+ export interface ChildrenProps {
3
+ readonly children?: ReactNode;
4
+ }
5
+ /** Renders its children when a Neon Auth session exists in this browser. */
6
+ export declare function SignedIn({ children }: ChildrenProps): ReactNode;
7
+ /** Renders its children when nobody is signed in. */
8
+ export declare function SignedOut({ children }: ChildrenProps): ReactNode;
9
+ export interface CanProps extends ChildrenProps {
10
+ /** A permission such as `lead:changeStatus`. The user's wildcards (`lead:*`, `*:*`) count. */
11
+ readonly permission: string;
12
+ /** Rendered when the user lacks the permission. Nothing renders while permissions load. */
13
+ readonly fallback?: ReactNode;
14
+ }
15
+ /**
16
+ * Renders its children when the signed-in user holds the permission. It only hides controls: the
17
+ * ontology service checks every read and action itself.
18
+ */
19
+ export declare function Can({ permission, children, fallback }: CanProps): ReactNode;
20
+ /** Ends the Neon Auth session in this browser, then opens `redirectTo`. */
21
+ export declare function signOut(redirectTo?: string): Promise<void>;
22
+ export interface UserMenuProps {
23
+ /** Where to go after signing out. Defaults to `/sign-in`. */
24
+ readonly signedOutPath?: string;
25
+ }
26
+ /** The signed-in user's name, email and roles, and a sign-out button. */
27
+ export declare function UserMenu({ signedOutPath }: UserMenuProps): import("react").JSX.Element | null;
@@ -0,0 +1,63 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { safeReturnTo } from "../return-to.js";
5
+ import { usePermissions, useSession, useUser } from "./hooks.js";
6
+ import { navigate } from "./navigation.js";
7
+ import { SIGN_IN_PAGE, authClient, clearSession } from "./store.js";
8
+ /** Renders its children when a Neon Auth session exists in this browser. */
9
+ export function SignedIn({ children }) {
10
+ return useSession().status === "signed-in" ? children : null;
11
+ }
12
+ /** Renders its children when nobody is signed in. */
13
+ export function SignedOut({ children }) {
14
+ return useSession().status === "signed-out" ? children : null;
15
+ }
16
+ /**
17
+ * Renders its children when the signed-in user holds the permission. It only hides controls: the
18
+ * ontology service checks every read and action itself.
19
+ */
20
+ export function Can({ permission, children, fallback = null }) {
21
+ const { isLoading, can } = usePermissions();
22
+ if (isLoading) {
23
+ return null;
24
+ }
25
+ return can(permission) ? children : fallback;
26
+ }
27
+ /** Ends the Neon Auth session in this browser, then opens `redirectTo`. */
28
+ export async function signOut(redirectTo = SIGN_IN_PAGE) {
29
+ try {
30
+ await authClient().signOut();
31
+ }
32
+ finally {
33
+ clearSession();
34
+ navigate(safeReturnTo(redirectTo));
35
+ }
36
+ }
37
+ /** The signed-in user's name, email and roles, and a sign-out button. */
38
+ export function UserMenu({ signedOutPath = SIGN_IN_PAGE }) {
39
+ const session = useSession();
40
+ const state = useUser();
41
+ const [signingOut, setSigningOut] = useState(false);
42
+ if (session.status !== "signed-in") {
43
+ return null;
44
+ }
45
+ const user = state.status === "signed-in" ? state.user : null;
46
+ const name = user?.name ?? session.user.name;
47
+ const email = user?.email ?? session.user.email;
48
+ const label = name ?? email ?? "Account";
49
+ const roles = user?.roles ?? [];
50
+ return (_jsxs("details", { className: "ss-user-menu", children: [_jsxs("summary", { className: "ss-user-menu-trigger", children: [_jsx("span", { className: "ss-avatar", "aria-hidden": "true", children: initials(label) }), _jsx("span", { className: "ss-user-menu-label", children: label })] }), _jsxs("div", { className: "ss-user-menu-panel", children: [name !== null && _jsx("p", { className: "ss-user-menu-name", children: name }), email !== null && _jsx("p", { className: "ss-user-menu-email", children: email }), roles.length > 0 && (_jsx("ul", { className: "ss-badges", "aria-label": "Roles", children: roles.map((role) => (_jsx("li", { className: "ss-badge", children: role }, role))) })), state.status === "error" && (_jsx("p", { className: "ss-error", role: "alert", children: state.error.detail })), _jsx("button", { type: "button", className: "ss-button ss-button--secondary", disabled: signingOut, "aria-busy": signingOut, onClick: () => {
51
+ setSigningOut(true);
52
+ void signOut(signedOutPath).catch(() => setSigningOut(false));
53
+ }, children: signingOut ? "Signing out…" : "Sign out" })] })] }));
54
+ }
55
+ function initials(label) {
56
+ const letters = label
57
+ .split(/[\s@._-]+/)
58
+ .filter((part) => part !== "")
59
+ .slice(0, 2)
60
+ .map((part) => part.charAt(0).toUpperCase())
61
+ .join("");
62
+ return letters === "" ? "?" : letters;
63
+ }
@@ -0,0 +1,19 @@
1
+ import type { SessionState, UserState } from "./store.js";
2
+ /** The Neon Auth session in this browser: `loading`, `signed-out`, `signed-in` or `unavailable`. */
3
+ export declare function useSession(): SessionState;
4
+ /**
5
+ * The signed-in user as the ontology knows them, from `GET /api/ontology/me`, loaded once per
6
+ * session. `status` is `loading`, `signed-out`, `signed-in` (with `user`) or `error` (with `error`;
7
+ * `error.code` is `not_invited` for people the app has not invited).
8
+ */
9
+ export declare function useUser(): UserState;
10
+ export interface Permissions {
11
+ /** True until the user's permissions are known. */
12
+ readonly isLoading: boolean;
13
+ readonly roles: ReadonlyArray<string>;
14
+ readonly permissions: ReadonlyArray<string>;
15
+ /** Whether the user's permissions cover `permission`, wildcards included. */
16
+ readonly can: (permission: string) => boolean;
17
+ }
18
+ /** The signed-in user's roles and permissions, from the same per-session answer as `useUser()`. */
19
+ export declare function usePermissions(): Permissions;
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { useEffect, useMemo, useSyncExternalStore } from "react";
3
+ import { SegmentStreamApiError } from "../errors.js";
4
+ import { isAllowed } from "../permissions.js";
5
+ import { LOADING_USER, SIGNED_OUT_USER, ensureUser, getServerSnapshot, getSnapshot, subscribe, } from "./store.js";
6
+ const UNAVAILABLE_USER = {
7
+ status: "error",
8
+ user: null,
9
+ error: new SegmentStreamApiError({
10
+ code: "auth_unavailable",
11
+ status: 503,
12
+ detail: "Neon Auth could not be reached. Try again in a moment.",
13
+ retryable: true,
14
+ }),
15
+ };
16
+ /** The Neon Auth session in this browser: `loading`, `signed-out`, `signed-in` or `unavailable`. */
17
+ export function useSession() {
18
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot).session;
19
+ }
20
+ /**
21
+ * The signed-in user as the ontology knows them, from `GET /api/ontology/me`, loaded once per
22
+ * session. `status` is `loading`, `signed-out`, `signed-in` (with `user`) or `error` (with `error`;
23
+ * `error.code` is `not_invited` for people the app has not invited).
24
+ */
25
+ export function useUser() {
26
+ const { session, users } = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
27
+ const sessionId = session.status === "signed-in" ? session.sessionId : null;
28
+ useEffect(() => {
29
+ if (sessionId !== null) {
30
+ ensureUser(sessionId);
31
+ }
32
+ }, [sessionId]);
33
+ switch (session.status) {
34
+ case "loading":
35
+ return LOADING_USER;
36
+ case "signed-out":
37
+ return SIGNED_OUT_USER;
38
+ case "unavailable":
39
+ return UNAVAILABLE_USER;
40
+ case "signed-in":
41
+ return users.get(session.sessionId) ?? LOADING_USER;
42
+ }
43
+ }
44
+ /** The signed-in user's roles and permissions, from the same per-session answer as `useUser()`. */
45
+ export function usePermissions() {
46
+ const state = useUser();
47
+ return useMemo(() => {
48
+ const user = state.status === "signed-in" ? state.user : null;
49
+ return {
50
+ isLoading: state.status === "loading",
51
+ roles: user?.roles ?? [],
52
+ permissions: user?.permissions ?? [],
53
+ can: (permission) => user !== null && isAllowed(permission, user),
54
+ };
55
+ }, [state]);
56
+ }
@@ -0,0 +1,10 @@
1
+ export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
2
+ export type { CanProps, ChildrenProps, UserMenuProps } from "./components.js";
3
+ export { usePermissions, useSession, useUser } from "./hooks.js";
4
+ export type { Permissions } from "./hooks.js";
5
+ export { SignIn, signInErrorMessage } from "./sign-in.js";
6
+ export type { SignInProps } from "./sign-in.js";
7
+ export type { SessionState, SessionUser, UserState } from "./store.js";
8
+ export { isAllowed } from "../permissions.js";
9
+ export type { CurrentUser } from "../me.js";
10
+ export { SegmentStreamApiError, NOT_INVITED } from "../errors.js";
@@ -0,0 +1,8 @@
1
+ "use client";
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";`.
4
+ export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
5
+ export { usePermissions, useSession, useUser } from "./hooks.js";
6
+ export { SignIn, signInErrorMessage } from "./sign-in.js";
7
+ export { isAllowed } from "../permissions.js";
8
+ export { SegmentStreamApiError, NOT_INVITED } from "../errors.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * A full page load, so that the server renders the next page with the session cookies Neon Auth
3
+ * has just set or cleared.
4
+ */
5
+ export declare function navigate(path: string): void;
@@ -0,0 +1,8 @@
1
+ "use client";
2
+ /**
3
+ * A full page load, so that the server renders the next page with the session cookies Neon Auth
4
+ * has just set or cleared.
5
+ */
6
+ export function navigate(path) {
7
+ window.location.assign(path);
8
+ }
@@ -0,0 +1,19 @@
1
+ export interface SignInProps {
2
+ /** Where to go once signed in. Defaults to the page's `returnTo` parameter, then `/`. */
3
+ readonly returnTo?: string;
4
+ /**
5
+ * Why the page was opened: `not_invited` when `requireUser()` found no invitation. Defaults to
6
+ * the page's `error` parameter.
7
+ */
8
+ readonly error?: string;
9
+ /** The heading. Defaults to "Sign in". */
10
+ readonly title?: string;
11
+ }
12
+ /**
13
+ * Email code sign-in with Neon Auth: the email address, then the six-digit code sent to it. New
14
+ * addresses get an account on first sign-in; whether they may use the app is the ontology's
15
+ * decision, which `requireUser()` reports as `not_invited`.
16
+ */
17
+ export declare function SignIn({ returnTo, error: reason, title }: SignInProps): import("react").JSX.Element;
18
+ /** A sentence for a Neon Auth (Better Auth) failure. */
19
+ export declare function signInErrorMessage(error: unknown): string;