@segmentstream-cloud/app 0.2.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 (47) hide show
  1. package/README.md +192 -87
  2. package/dist/app.d.ts +35 -8
  3. package/dist/app.js +73 -8
  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 +6 -18
  9. package/dist/data.js +17 -36
  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 +18 -8
  14. package/dist/index.js +11 -6
  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/permissions.d.ts +17 -0
  24. package/dist/permissions.js +33 -0
  25. package/dist/proxy.d.ts +2 -0
  26. package/dist/proxy.js +29 -13
  27. package/dist/return-to.d.ts +2 -0
  28. package/dist/return-to.js +24 -0
  29. package/dist/server.d.ts +3 -0
  30. package/dist/server.js +35 -5
  31. package/dist/sign-in.d.ts +5 -1
  32. package/dist/sign-in.js +17 -4
  33. package/dist/tokens.d.ts +22 -3
  34. package/dist/ui/components.d.ts +27 -0
  35. package/dist/ui/components.js +63 -0
  36. package/dist/ui/hooks.d.ts +19 -0
  37. package/dist/ui/hooks.js +56 -0
  38. package/dist/ui/index.d.ts +10 -0
  39. package/dist/ui/index.js +8 -0
  40. package/dist/ui/navigation.d.ts +5 -0
  41. package/dist/ui/navigation.js +8 -0
  42. package/dist/ui/sign-in.d.ts +19 -0
  43. package/dist/ui/sign-in.js +198 -0
  44. package/dist/ui/store.d.ts +61 -0
  45. package/dist/ui/store.js +155 -0
  46. package/dist/ui/styles.css +249 -0
  47. package/package.json +24 -7
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;
@@ -0,0 +1,198 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
4
+ import { safeReturnTo } from "../return-to.js";
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
+ }
17
+ /**
18
+ * Email code sign-in with Neon Auth: the email address, then the six-digit code sent to it. New
19
+ * addresses get an account on first sign-in; whether they may use the app is the ontology's
20
+ * decision, which `requireUser()` reports as `not_invited`.
21
+ */
22
+ export function SignIn({ returnTo, error: reason, title = "Sign in" }) {
23
+ const ids = useId();
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
+ /** Seconds until another code may be requested. */
34
+ const [resendIn, setResendIn] = useState(0);
35
+ const [switchingAccount, setSwitchingAccount] = useState(false);
36
+ const codeInput = useRef(null);
37
+ const verifying = useRef(false);
38
+ const resendTimer = useRef(undefined);
39
+ const destination = safeReturnTo(returnTo ?? pageReturnTo);
40
+ const notInvited = (reason ?? pageError) === NOT_INVITED && !switchingAccount;
41
+ useEffect(() => () => clearInterval(resendTimer.current), []);
42
+ useEffect(() => {
43
+ if (step === "code") {
44
+ codeInput.current?.focus();
45
+ }
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
+ }
135
+ const errorId = `${ids}-error`;
136
+ const sessionEmail = session.status === "signed-in" ? (session.user.email ?? session.user.name) : null;
137
+ if (notInvited) {
138
+ 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
+ }
140
+ if (session.status === "signed-in" && step === "email" && pending === null && !switchingAccount) {
141
+ 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
+ }
143
+ 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" })] })] }));
145
+ }
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 ", CODE_LENGTH, "-digit code we sent to ", _jsx("strong", { children: email }), "."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
147
+ event.preventDefault();
148
+ void verify(code);
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: CODE_LENGTH, required: true, value: code, onChange: (event) => {
150
+ const digits = event.target.value.replace(/\D/g, "").slice(0, CODE_LENGTH);
151
+ setCode(digits);
152
+ if (digits.length === CODE_LENGTH) {
153
+ void verify(digits);
154
+ }
155
+ }, 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
+ ? "Sending…"
157
+ : resendIn > 0
158
+ ? `Resend code in ${resendIn} s`
159
+ : "Resend code" }), _jsx("button", { type: "button", className: "ss-link", onClick: changeEmail, disabled: pending !== null, children: "Use another email" })] })] }));
160
+ }
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,61 @@
1
+ import { createAuthClient } from "@neondatabase/auth/next";
2
+ import { NOT_INVITED, SegmentStreamApiError } from "../errors.js";
3
+ import type { CurrentUser } from "../me.js";
4
+ /** The Neon Auth API the app serves from `app/src/app/api/auth/[...path]/route.ts`. */
5
+ export declare const AUTH_API_PATH = "/api/auth";
6
+ /** The app's sign-in page. */
7
+ export declare const SIGN_IN_PAGE = "/sign-in";
8
+ export { NOT_INVITED };
9
+ /** The person Neon Auth has signed in, before the ontology is asked about them. */
10
+ export interface SessionUser {
11
+ readonly id: string;
12
+ readonly email: string | null;
13
+ readonly name: string | null;
14
+ }
15
+ export type SessionState = {
16
+ readonly status: "loading";
17
+ } | {
18
+ readonly status: "signed-out";
19
+ } | {
20
+ readonly status: "signed-in";
21
+ readonly sessionId: string;
22
+ readonly user: SessionUser;
23
+ } | {
24
+ readonly status: "unavailable";
25
+ };
26
+ /** `useUser()`: the `/api/ontology/me` answer for the session, loaded once per session. */
27
+ export type UserState = {
28
+ readonly status: "loading";
29
+ readonly user: null;
30
+ readonly error: null;
31
+ } | {
32
+ readonly status: "signed-out";
33
+ readonly user: null;
34
+ readonly error: null;
35
+ } | {
36
+ readonly status: "signed-in";
37
+ readonly user: CurrentUser;
38
+ readonly error: null;
39
+ } | {
40
+ readonly status: "error";
41
+ readonly user: null;
42
+ readonly error: SegmentStreamApiError;
43
+ };
44
+ interface Snapshot {
45
+ readonly session: SessionState;
46
+ /** `/me` answers by Neon Auth session id. */
47
+ readonly users: ReadonlyMap<string, UserState>;
48
+ }
49
+ export declare const LOADING_USER: UserState;
50
+ export declare const SIGNED_OUT_USER: UserState;
51
+ /** The Neon Auth browser client, created on first use. It talks to the app's `/api/auth`. */
52
+ export declare function authClient(): ReturnType<typeof createAuthClient>;
53
+ export declare function subscribe(listener: () => void): () => void;
54
+ export declare function getSnapshot(): Snapshot;
55
+ export declare function getServerSnapshot(): Snapshot;
56
+ /** Reads the session again, after signing in or out. */
57
+ export declare function refreshSession(): Promise<void>;
58
+ /** Forgets the session and every `/me` answer, after signing out. */
59
+ export declare function clearSession(): void;
60
+ /** Starts loading `/me` for a session unless it is loaded or loading. */
61
+ export declare function ensureUser(sessionId: string): void;