@stwrd-auth/react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented in this file.
4
+
5
+ The format is based on Keep a Changelog 1.1.0, and this package adheres to Semantic Versioning (SemVer).
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ Initial release.
10
+
11
+ ### Added
12
+
13
+ - `StwrdProvider` and the `useSession`, `useUser` and `useOrganization` hooks, reading the session from `GET /auth/session`.
14
+ - Session status `loading`, `authenticated`, `anonymous` and `unavailable`, with retries and cross-tab refresh.
15
+ - Guard components: `SignedIn`, `SignedOut` and `Protect`.
16
+ - Account components: `UserButton` and `OrganizationSwitcher`.
17
+ - Types: `StwrdUser`, `StwrdOrganization`, `SessionResponse`, `StwrdState`, `ConsentState` and `OrganizationOption`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stwrd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,140 @@
1
- # Temporary Holding Version
1
+ # @stwrd-auth/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ stwrd SDK for React (backend-for-frontend profile): a session provider, hooks and components that read the session from the `/auth/session` endpoint of your server.
4
+
5
+ ## What it is
6
+
7
+ [stwrd](https://stwrd.dev) is a multi-tenant identity provider. In the backend-for-frontend (BFF) profile your server performs the OpenID Connect flow and keeps the tokens; the browser only holds a session cookie. This package is the front-end half: it asks your server who is signed in and exposes that to React components. It never sees a token.
8
+
9
+ It needs a server that serves the `/auth/*` routes on the same origin as your front end: [`@stwrd-auth/node`](https://www.npmjs.com/package/@stwrd-auth/node) (Express), [`@stwrd-auth/workers`](https://www.npmjs.com/package/@stwrd-auth/workers) (Cloudflare Workers) or the Python SDK (`stwrd-auth` on PyPI). Authorization decisions stay on the server; the guards in this package only decide what to render.
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ npm install @stwrd-auth/react
15
+ ```
16
+
17
+ Requires React 18 or newer (peer dependency) and a bundler that understands ESM. The package is ESM only and ships its own TypeScript declarations (install `@types/react` for TypeScript projects).
18
+
19
+ ## Minimal example
20
+
21
+ ```tsx
22
+ import {
23
+ OrganizationSwitcher,
24
+ Protect,
25
+ SignedIn,
26
+ SignedOut,
27
+ StwrdProvider,
28
+ UserButton,
29
+ useSession,
30
+ } from "@stwrd-auth/react";
31
+
32
+ export function Home() {
33
+ const session = useSession();
34
+ if (session.status === "loading") return null;
35
+ if (session.status === "unavailable") return <p>Service unavailable.</p>;
36
+ return (
37
+ <>
38
+ <SignedOut>
39
+ <a href="/auth/sign-in?return_to=/">Sign in</a>
40
+ </SignedOut>
41
+ <SignedIn>
42
+ <p>Hello, {session.user?.display_name ?? session.user?.email}.</p>
43
+ <UserButton />
44
+ <OrganizationSwitcher /> {/* lists the person's organizations (needs the `org` scope) */}
45
+ <Protect role="org:admin" permission="members:invite" fallback={<p>No permission.</p>}>
46
+ <a href="/members">Members</a>
47
+ </Protect>
48
+ </SignedIn>
49
+ </>
50
+ );
51
+ }
52
+
53
+ export function App() {
54
+ return (
55
+ <StwrdProvider baseUrl="/auth">
56
+ <Home />
57
+ </StwrdProvider>
58
+ );
59
+ }
60
+ ```
61
+
62
+ Mount `App` as usual (`createRoot(document.getElementById("root")!).render(<App />)`). During development, proxy `/auth` to your server so that the cookie stays on one origin.
63
+
64
+ ## Configuration
65
+
66
+ `StwrdProvider` takes one option:
67
+
68
+ | Prop | Required | Default | Description |
69
+ |---|---|---|---|
70
+ | `baseUrl` | no | `/auth` | Where the server serves the routes; it must match the server's route prefix (`STWRD_PREFIX`). Changing it resets the session state. |
71
+
72
+ The components render fixed English labels and expose `data-stwrd` attributes (`user-button`, `user-button-label`, `user-button-account-link`, `user-button-sign-out`, `organization-switcher`, `organization-switcher-select`, `organization-switcher-confirm`) as styling hooks. The `className` prop applies to the outer element.
73
+
74
+ ## API reference
75
+
76
+ ### Provider and hooks
77
+
78
+ | Export | Description |
79
+ |---|---|
80
+ | `StwrdProvider` | Reads `GET {baseUrl}/session` with credentials, keeps the state and shares it with the tree. Other tabs are told to refresh when the session changes. |
81
+ | `useSession()` | `{ status, user, organization, consents, accountUrl, refresh, signOut, listOrganizations, selectOrganization }` |
82
+ | `useUser()` | The signed-in person, or `null`. |
83
+ | `useOrganization()` | The active organization, or `null`. |
84
+
85
+ `status` is one of:
86
+
87
+ | Status | Meaning |
88
+ |---|---|
89
+ | `loading` | The first answer has not arrived. |
90
+ | `authenticated` | A person is signed in. |
91
+ | `anonymous` | Nobody is signed in. |
92
+ | `unavailable` | The server could not answer, or answered something invalid. The last known state is kept. The provider retries three times with growing delays (2, 4 and 8 seconds). |
93
+
94
+ While `unavailable`, `useUser()` and `useOrganization()` may still return the last known values; check `status` before using them for access decisions. `refresh()` asks the server again. `signOut()` navigates with a `POST` form to `{baseUrl}/sign-out`, including the CSRF token the server provided. `listOrganizations()` resolves to the person's organizations, or `null` when the server has organization selection disabled. `selectOrganization(id)` starts a real navigation through the server to switch the active organization.
95
+
96
+ ### Components
97
+
98
+ | Component | Description |
99
+ |---|---|
100
+ | `SignedIn`, `SignedOut` | Render their children only when `status` is `authenticated` or `anonymous`. |
101
+ | `Protect` | Renders its children when the person has every control given (`role`, `permission`, `condition(user)`); otherwise `fallback`. Capabilities come from the organization when there is one, otherwise from the individual lists. Renders nothing while `loading` or `unavailable`. |
102
+ | `UserButton` | The person's name (or email, or id), a link to the account page and a sign-out button. |
103
+ | `OrganizationSwitcher` | A selector with a confirm button when the person has several organizations; otherwise a link to the account page of the organization. |
104
+
105
+ ### Types
106
+
107
+ `StwrdUser` (`id`, `email`, `email_verified`, `display_name`, `avatar_url`, `roles`, `permissions`), `StwrdOrganization` (`id`, `display_name`, `roles`, `permissions`), `SessionResponse`, `StwrdState`, `ConsentState` and `OrganizationOption`. `consents` is a map of consent names to `accepted`, `declined` or `pending`. `accountUrl` is the address of the person's account page.
108
+
109
+ ## Security
110
+
111
+ - No token, claim or secret reaches the browser through this package. The session cookie is set by your server; the provider only sends `credentials: "include"` requests to `{baseUrl}/session` and `{baseUrl}/organizations`.
112
+ - The provider accepts only a response with the exact expected shape and refuses anything else (status `unavailable`), including an `account_url` that is not an `http` or `https` URL.
113
+ - Sign-out and the organization switch are `POST` navigations that include the CSRF token issued by the server.
114
+ - The guards (`SignedIn`, `Protect`, ...) only change what is rendered. Enforce authorization on the server.
115
+ - Components never show session content or the `Protect` fallback while the state is `loading` or `unavailable`.
116
+ - Supported versions and how to report a vulnerability are described in the [security policy](https://github.com/stwrd-dev/sdk-js/security/policy).
117
+
118
+ ## Error handling and troubleshooting
119
+
120
+ | Symptom | Cause | What to do |
121
+ |---|---|---|
122
+ | `status` stays `unavailable` | The server answered with an error, malformed JSON or a response that does not match the expected shape. | Check that `baseUrl` is the route prefix of your server and that `GET {baseUrl}/session` answers 200 JSON with `authenticated`, `user`, `organization`, `consents`, `csrf_token` and `account_url`. |
123
+ | `status` is `anonymous` after sign-in | The cookie is not sent: different origin, or a development proxy missing. | Serve the front end and the routes from one origin. |
124
+ | `OrganizationSwitcher` shows a link, not a selector | The person has one organization, or organization selection is not enabled. | Add `org` to the server scope (`STWRD_SCOPE`) and allow it for the application. |
125
+ | `useSession() ... was used outside <StwrdProvider>` | A hook or component is rendered outside the provider. | Wrap the tree in `StwrdProvider`. |
126
+
127
+ ## Compatibility
128
+
129
+ - React 18 or newer (the test suite runs against React 18).
130
+ - ESM only; TypeScript declarations included.
131
+ - Browsers that support `fetch`. `BroadcastChannel` is used for cross-tab updates when available.
132
+ - Server side: a BFF that serves `GET /auth/session` in the documented shape. There is no Next.js adapter yet.
133
+
134
+ ## Versioning
135
+
136
+ This package follows Semantic Versioning (SemVer). While the version is below 1.0, minor releases may contain breaking changes. Changes are listed in `CHANGELOG.md`.
137
+
138
+ ## License
139
+
140
+ MIT © 2026 stwrd. See `LICENSE`.
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ import type { OrganizationOption, StwrdState } from "./types.js";
3
+ export type StwrdContextValue = StwrdState & {
4
+ refresh(): Promise<void>;
5
+ signOut(): void;
6
+ /** The person's own organizations; `null` when organization selection is not enabled on the BFF. */
7
+ listOrganizations(): Promise<OrganizationOption[] | null>;
8
+ /** Starts a real OIDC navigation for the chosen organization; the page leaves. */
9
+ selectOrganization(id: string): void;
10
+ };
11
+ export interface StwrdProviderProps {
12
+ baseUrl?: string;
13
+ children?: ReactNode;
14
+ }
15
+ export declare function StwrdProvider({ baseUrl, children }: StwrdProviderProps): import("react").JSX.Element;
16
+ export declare function useSessionContext(): StwrdContextValue;
@@ -0,0 +1,175 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
3
+ const Context = createContext(null);
4
+ const empty = () => ({ status: "loading", user: null, organization: null, consents: {}, accountUrl: "" });
5
+ const backoff = [2000, 4000, 8000];
6
+ // Other tabs only ever receive "something changed, ask again": never tokens or claims.
7
+ const CHANNEL = "stwrd:session";
8
+ const CHANGED_FLAG = "stwrd:session-changed";
9
+ function organizationOptions(v) {
10
+ if (!record(v) || !Array.isArray(v.organizations))
11
+ return null;
12
+ const list = v.organizations;
13
+ return list.every(o => record(o) && keys(o, ["id", "display_name", "current"]) && nonempty(o.id) && nullableString(o.display_name) && typeof o.current === "boolean") ? list : null;
14
+ }
15
+ const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
16
+ const keys = (v, expected) => Object.keys(v).length === expected.length && expected.every(k => Object.hasOwn(v, k));
17
+ const strings = (v) => Array.isArray(v) && v.every(x => typeof x === "string");
18
+ const nullableString = (v) => v === null || typeof v === "string";
19
+ /** A rendered `href` must never carry a `javascript:` (or any non-web) scheme. */
20
+ const webUrl = (v) => { if (!nonempty(v))
21
+ return false; try {
22
+ const p = new URL(v, "https://placeholder.invalid").protocol;
23
+ return p === "https:" || p === "http:";
24
+ }
25
+ catch {
26
+ return false;
27
+ } };
28
+ const nonempty = (v) => typeof v === "string" && v.trim().length > 0;
29
+ /** Reject incomplete, legacy and contradictory browser responses before publishing authority. */
30
+ function valid(v) {
31
+ if (!record(v) || !keys(v, ["authenticated", "user", "organization", "consents", "csrf_token", "account_url"]) || typeof v.authenticated !== "boolean" || !webUrl(v.account_url) || !record(v.consents) || !Object.values(v.consents).every(x => x === "accepted" || x === "declined" || x === "pending"))
32
+ return false;
33
+ if (!v.authenticated)
34
+ return v.user === null && v.organization === null && v.csrf_token === null && Object.keys(v.consents).length === 0;
35
+ const u = v.user, o = v.organization;
36
+ if (!record(u) || !keys(u, ["id", "email", "email_verified", "display_name", "avatar_url", "roles", "permissions"]) || !nonempty(u.id) || !nullableString(u.email) || typeof u.email_verified !== "boolean" || !nullableString(u.display_name) || !nullableString(u.avatar_url) || !strings(u.roles) || !strings(u.permissions) || !nonempty(v.csrf_token))
37
+ return false;
38
+ return o === null || (record(o) && keys(o, ["id", "display_name", "roles", "permissions"]) && nonempty(o.id) && nonempty(o.display_name) && strings(o.roles) && strings(o.permissions) && u.roles.length === 0 && u.permissions.length === 0);
39
+ }
40
+ export function StwrdProvider({ baseUrl = "/auth", children }) {
41
+ const [state, setState] = useState(empty);
42
+ const csrf = useRef(null);
43
+ // Stable callbacks always target the committed configuration, including retained references.
44
+ const currentBaseUrl = useRef(baseUrl);
45
+ const generation = useRef(0);
46
+ const signingOut = useRef(false);
47
+ const [attempt, setAttempt] = useState(0);
48
+ const channel = useRef(null);
49
+ const refresh = useCallback(async () => {
50
+ if (signingOut.current)
51
+ return;
52
+ const request = ++generation.current;
53
+ try {
54
+ const response = await fetch(`${currentBaseUrl.current}/session`, { credentials: "include", cache: "no-store" });
55
+ if (!response.ok)
56
+ throw new Error("Session unavailable");
57
+ const body = await response.json();
58
+ if (!valid(body))
59
+ throw new Error("Invalid session response");
60
+ if (request !== generation.current)
61
+ return;
62
+ csrf.current = body.csrf_token;
63
+ const snapshot = { user: body.user, organization: body.organization, consents: body.consents, accountUrl: body.account_url };
64
+ setState(body.authenticated ? { ...snapshot, status: "authenticated", user: body.user } : { ...snapshot, status: "anonymous", user: null, organization: null });
65
+ }
66
+ catch {
67
+ if (request === generation.current)
68
+ setState(previous => ({ ...previous, status: "unavailable" }));
69
+ }
70
+ }, []);
71
+ useEffect(() => {
72
+ currentBaseUrl.current = baseUrl;
73
+ signingOut.current = false;
74
+ csrf.current = null;
75
+ setState(empty());
76
+ setAttempt(0);
77
+ void refresh();
78
+ return () => { ++generation.current; };
79
+ }, [baseUrl, refresh]);
80
+ useEffect(() => {
81
+ if (typeof BroadcastChannel === "undefined")
82
+ return;
83
+ const bus = new BroadcastChannel(CHANNEL);
84
+ channel.current = bus;
85
+ bus.onmessage = () => { void refresh(); };
86
+ try {
87
+ // A navigation that changed the session (organization switch, sign-out) set this flag
88
+ // before leaving; the page it lands on tells the other tabs once.
89
+ if (sessionStorage.getItem(CHANGED_FLAG)) {
90
+ sessionStorage.removeItem(CHANGED_FLAG);
91
+ bus.postMessage("changed");
92
+ }
93
+ }
94
+ catch { /* storage unavailable: other tabs refresh on their own schedule */ }
95
+ return () => { bus.close(); channel.current = null; };
96
+ }, [refresh]);
97
+ useEffect(() => {
98
+ // The only other signal is the BroadcastChannel notice, which a hidden, frozen or
99
+ // restored tab can miss: ask again whenever the tab becomes visible or comes back
100
+ // from the back/forward cache.
101
+ const onVisible = () => { if (document.visibilityState === "visible")
102
+ void refresh(); };
103
+ const onPageShow = (event) => { if (event.persisted)
104
+ void refresh(); };
105
+ document.addEventListener("visibilitychange", onVisible);
106
+ window.addEventListener("pageshow", onPageShow);
107
+ return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("pageshow", onPageShow); };
108
+ }, [refresh]);
109
+ useEffect(() => {
110
+ if (state.status !== "unavailable") {
111
+ setAttempt(0);
112
+ return;
113
+ }
114
+ if (attempt >= backoff.length || signingOut.current)
115
+ return;
116
+ const timer = setTimeout(() => { setAttempt(n => n + 1); void refresh(); }, backoff[attempt]);
117
+ return () => clearTimeout(timer);
118
+ }, [state.status, attempt, refresh]);
119
+ const markChanged = () => { try {
120
+ sessionStorage.setItem(CHANGED_FLAG, "1");
121
+ }
122
+ catch { /* optional */ } };
123
+ const post = (action, fields) => {
124
+ const form = document.createElement("form");
125
+ form.method = "POST";
126
+ form.action = `${currentBaseUrl.current}/${action}`;
127
+ form.style.display = "none";
128
+ for (const [name, value] of Object.entries(fields)) {
129
+ const field = document.createElement("input");
130
+ field.type = "hidden";
131
+ field.name = name;
132
+ field.value = value;
133
+ form.appendChild(field);
134
+ }
135
+ document.body.appendChild(form);
136
+ // A real navigation follows the BFF/IdP redirect chain; fetch would not.
137
+ form.submit();
138
+ };
139
+ const listOrganizations = useCallback(async () => {
140
+ const response = await fetch(`${currentBaseUrl.current}/organizations`, { credentials: "include", cache: "no-store" });
141
+ if (response.status === 404)
142
+ return null;
143
+ // The session ended after `/session` was read: show the truth, not a stale "signed in".
144
+ if (response.status === 401) {
145
+ void refresh();
146
+ return null;
147
+ }
148
+ if (!response.ok)
149
+ throw new Error("Organizations unavailable");
150
+ const options = organizationOptions(await response.json());
151
+ if (options === null)
152
+ throw new Error("Invalid organizations response");
153
+ return options;
154
+ }, [refresh]);
155
+ const selectOrganization = useCallback((id) => {
156
+ if (signingOut.current || !csrf.current)
157
+ return;
158
+ markChanged();
159
+ post("organization", { csrf_token: csrf.current, organization_id: id, return_to: `${window.location.pathname}${window.location.search}` });
160
+ }, []);
161
+ const signOut = useCallback(() => {
162
+ signingOut.current = true;
163
+ ++generation.current;
164
+ setState(previous => ({ ...previous, status: "loading" }));
165
+ markChanged();
166
+ post("sign-out", { csrf_token: csrf.current ?? "" });
167
+ }, []);
168
+ return _jsx(Context.Provider, { value: { ...state, refresh, signOut, listOrganizations, selectOrganization }, children: children });
169
+ }
170
+ export function useSessionContext() {
171
+ const value = useContext(Context);
172
+ if (!value)
173
+ throw new Error("useSession() (or a stwrd component) was used outside <StwrdProvider>.");
174
+ return value;
175
+ }
@@ -0,0 +1,26 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { StwrdUser } from "./types.js";
3
+ export interface SignedInProps {
4
+ children?: ReactNode;
5
+ }
6
+ export declare function SignedIn({ children }: SignedInProps): ReactElement | null;
7
+ export interface SignedOutProps {
8
+ children?: ReactNode;
9
+ }
10
+ export declare function SignedOut({ children }: SignedOutProps): ReactElement | null;
11
+ export interface ProtectProps {
12
+ role?: string;
13
+ permission?: string;
14
+ condition?: (user: StwrdUser) => boolean;
15
+ fallback?: ReactNode;
16
+ children?: ReactNode;
17
+ }
18
+ export declare function Protect({ role, permission, condition, fallback, children }: ProtectProps): ReactElement | null;
19
+ export interface UserButtonProps {
20
+ className?: string;
21
+ }
22
+ export declare function UserButton({ className }: UserButtonProps): ReactElement | null;
23
+ export interface OrganizationSwitcherProps {
24
+ className?: string;
25
+ }
26
+ export declare function OrganizationSwitcher({ className, }: OrganizationSwitcherProps): ReactElement | null;
@@ -0,0 +1,79 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { useSession } from "./useSession.js";
4
+ export function SignedIn({ children }) {
5
+ const { status } = useSession();
6
+ if (status !== "authenticated") {
7
+ return null;
8
+ }
9
+ return _jsx(_Fragment, { children: children });
10
+ }
11
+ export function SignedOut({ children }) {
12
+ const { status } = useSession();
13
+ if (status !== "anonymous") {
14
+ return null;
15
+ }
16
+ return _jsx(_Fragment, { children: children });
17
+ }
18
+ export function Protect({ role, permission, condition, fallback = null, children }) {
19
+ const { user, organization, status } = useSession();
20
+ if (status === "loading" || status === "unavailable") {
21
+ // `unavailable` is handled with `loading`, not with `user === null`: the
22
+ // `fallback` is what a person WITHOUT permission sees, and whether this
23
+ // person has it is not known.
24
+ return null;
25
+ }
26
+ if (user === null) {
27
+ return _jsx(_Fragment, { children: fallback });
28
+ }
29
+ const capabilities = organization ?? user;
30
+ if ((role !== undefined && !capabilities.roles.includes(role)) || (permission !== undefined && !capabilities.permissions.includes(permission)) || (condition && !condition(user))) {
31
+ return _jsx(_Fragment, { children: fallback });
32
+ }
33
+ return _jsx(_Fragment, { children: children });
34
+ }
35
+ export function UserButton({ className }) {
36
+ const { user, accountUrl, signOut, status } = useSession();
37
+ if (status !== "authenticated") {
38
+ return null;
39
+ }
40
+ const label = user.display_name ?? user.email ?? user.id;
41
+ return (_jsxs("div", { className: className, "data-stwrd": "user-button", children: [_jsx("span", { "data-stwrd": "user-button-label", children: label }), _jsx("a", { href: accountUrl, "data-stwrd": "user-button-account-link", children: "Account" }), _jsx("button", { type: "button", onClick: signOut, "data-stwrd": "user-button-sign-out", children: "Sign out" })] }));
42
+ }
43
+ /** Two organizations can share a display name; the id tells them apart. */
44
+ function optionLabel(option, all) {
45
+ const name = option.display_name ?? option.id;
46
+ const repeated = all.filter(other => (other.display_name ?? other.id) === name).length > 1;
47
+ return repeated && option.display_name !== null ? `${name} (${option.id.slice(0, 8)})` : name;
48
+ }
49
+ export function OrganizationSwitcher({ className, }) {
50
+ const { organization, accountUrl, status, user, listOrganizations, selectOrganization } = useSession();
51
+ const [options, setOptions] = useState(null);
52
+ const [pending, setPending] = useState(null);
53
+ const signedIn = status === "authenticated" && organization !== null;
54
+ const identity = signedIn ? `${user?.id}:${organization?.id}` : null;
55
+ useEffect(() => {
56
+ // A late answer for a previous identity or status is dropped; losing
57
+ // authority (503) or the session clears the list.
58
+ setOptions(null);
59
+ setPending(null);
60
+ if (identity === null)
61
+ return;
62
+ let current = true;
63
+ listOrganizations().then(list => { if (current)
64
+ setOptions(list); }, () => { if (current)
65
+ setOptions(null); });
66
+ return () => { current = false; };
67
+ }, [identity, listOrganizations]);
68
+ if (!signedIn || organization === null) {
69
+ return null;
70
+ }
71
+ if (options !== null && options.length > 1) {
72
+ // Choosing and confirming are two acts: a key press on a closed <select>
73
+ // fires `change` at once, and the navigation would drop unsaved work.
74
+ const chosen = pending ?? organization.id;
75
+ return (_jsxs("span", { className: className, "data-stwrd": "organization-switcher", children: [_jsxs("select", { "aria-label": "Organization", "data-stwrd": "organization-switcher-select", onChange: event => setPending(event.target.value), value: options.some(option => option.id === chosen) ? chosen : "", children: [options.every(option => option.id !== organization.id) && _jsx("option", { disabled: true, value: "", children: organization.display_name }), options.map(option => _jsx("option", { value: option.id, children: optionLabel(option, options) }, option.id))] }), _jsx("button", { type: "button", "data-stwrd": "organization-switcher-confirm", disabled: pending === null || pending === organization.id, onClick: () => { if (pending !== null && pending !== organization.id)
76
+ selectOrganization(pending); }, children: "Switch" })] }));
77
+ }
78
+ return (_jsx("a", { className: className, "data-stwrd": "organization-switcher", href: `${accountUrl}/organizations`, children: organization.display_name }));
79
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@stwrd-auth/react` — the React SDK, BFF mode: the provider, the session
3
+ * hooks and the guard and account components.
4
+ */
5
+ export { StwrdProvider } from "./StwrdProvider.js";
6
+ export type { StwrdProviderProps } from "./StwrdProvider.js";
7
+ export { useSession, useUser, useOrganization } from "./useSession.js";
8
+ export { OrganizationSwitcher, Protect, SignedIn, SignedOut, UserButton } from "./components.js";
9
+ export type { OrganizationSwitcherProps, ProtectProps, SignedInProps, SignedOutProps, UserButtonProps, } from "./components.js";
10
+ export type { ConsentState, OrganizationOption, SessionResponse, StwrdState, StwrdUser, StwrdOrganization } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@stwrd-auth/react` — the React SDK, BFF mode: the provider, the session
3
+ * hooks and the guard and account components.
4
+ */
5
+ export { StwrdProvider } from "./StwrdProvider.js";
6
+ export { useSession, useUser, useOrganization } from "./useSession.js";
7
+ export { OrganizationSwitcher, Protect, SignedIn, SignedOut, UserButton } from "./components.js";
@@ -0,0 +1,47 @@
1
+ export type ConsentState = "accepted" | "declined" | "pending";
2
+ export interface StwrdUser {
3
+ id: string;
4
+ email: string | null;
5
+ email_verified: boolean;
6
+ display_name: string | null;
7
+ avatar_url: string | null;
8
+ roles: string[];
9
+ permissions: string[];
10
+ }
11
+ export interface StwrdOrganization {
12
+ id: string;
13
+ display_name: string;
14
+ roles: string[];
15
+ permissions: string[];
16
+ }
17
+ export interface SessionResponse {
18
+ authenticated: boolean;
19
+ user: StwrdUser | null;
20
+ organization: StwrdOrganization | null;
21
+ consents: Record<string, ConsentState>;
22
+ csrf_token: string | null;
23
+ account_url: string;
24
+ }
25
+ interface Snapshot {
26
+ user: StwrdUser | null;
27
+ organization: StwrdOrganization | null;
28
+ consents: Record<string, ConsentState>;
29
+ accountUrl: string;
30
+ }
31
+ export type StwrdState = (Snapshot & {
32
+ status: "loading" | "unavailable";
33
+ }) | (Snapshot & {
34
+ status: "authenticated";
35
+ user: StwrdUser;
36
+ }) | (Snapshot & {
37
+ status: "anonymous";
38
+ user: null;
39
+ organization: null;
40
+ });
41
+ /** One of the signed-in person's own organizations, as listed by the BFF. */
42
+ export interface OrganizationOption {
43
+ id: string;
44
+ display_name: string | null;
45
+ current: boolean;
46
+ }
47
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare function useSession(): import("./StwrdProvider.js").StwrdContextValue;
2
+ export declare function useUser(): import("./types.js").StwrdUser | null;
3
+ export declare function useOrganization(): import("./types.js").StwrdOrganization | null;
@@ -0,0 +1,4 @@
1
+ import { useSessionContext } from "./StwrdProvider.js";
2
+ export function useSession() { return useSessionContext(); }
3
+ export function useUser() { return useSession().user; }
4
+ export function useOrganization() { return useSession().organization; }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "@stwrd-auth/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "stwrd SDK for React (backend-for-frontend profile): a session provider, hooks and components that read the session from the /auth/session endpoint.",
5
+ "keywords": [
6
+ "stwrd",
7
+ "authentication",
8
+ "oidc",
9
+ "oauth2",
10
+ "react",
11
+ "bff",
12
+ "sessions",
13
+ "hooks"
14
+ ],
15
+ "license": "MIT",
16
+ "author": {
17
+ "name": "stwrd",
18
+ "url": "https://stwrd.dev"
19
+ },
20
+ "homepage": "https://stwrd.dev",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/stwrd-dev/sdk-js.git",
24
+ "directory": "packages/react"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/stwrd-dev/sdk-js/issues"
28
+ },
29
+ "type": "module",
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ }
37
+ },
38
+ "sideEffects": false,
39
+ "files": [
40
+ "dist",
41
+ "!dist/.tsbuildinfo",
42
+ "README.md",
43
+ "CHANGELOG.md",
44
+ "LICENSE"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "prepack": "npm run build",
51
+ "build": "tsc -p tsconfig.json",
52
+ "test": "vitest run",
53
+ "lint": "tsc --noEmit -p tsconfig.json"
54
+ },
55
+ "engines": {
56
+ "node": ">=18"
57
+ },
58
+ "peerDependencies": {
59
+ "react": ">=18"
60
+ },
61
+ "devDependencies": {
62
+ "@testing-library/dom": "^10.4.0",
63
+ "@testing-library/jest-dom": "^6.6.3",
64
+ "@testing-library/react": "^16.1.0",
65
+ "@types/node": "^22.10.0",
66
+ "@types/react": "^18.3.0",
67
+ "@types/react-dom": "^18.3.0",
68
+ "jsdom": "^25.0.0",
69
+ "react": "^18.3.0",
70
+ "react-dom": "^18.3.0",
71
+ "typescript": "^5.6.0",
72
+ "vitest": "^4.1.10"
73
+ }
74
+ }