@payload-auth-link/react 0.0.0-stage → 0.1.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 +44 -2
- package/dist/components.d.ts +54 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +49 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +46 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +94 -0
- package/dist/provider.js.map +1 -0
- package/dist/session.d.ts +17 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +46 -0
- package/dist/session.js.map +1 -0
- package/package.json +53 -4
- package/src/components.tsx +127 -0
- package/src/index.ts +17 -0
- package/src/provider.tsx +161 -0
- package/src/session.ts +57 -0
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @payload-auth-link/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Provider-agnostic React UI for payload-auth-link: a session context plus unstyled sign-in, sign-out, and user-menu components. It has no Next.js or Payload imports, so it works under any framework adapter.
|
|
4
|
+
|
|
5
|
+
Part of [payload-auth-link](https://github.com/jellologic/payload-auth-link): an auth plugin for Payload CMS with swappable identity providers (Better Auth, Auth.js) and first-class Next.js and TanStack Start adapters.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @payload-auth-link/react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies: `react` ^19.0.0.
|
|
14
|
+
|
|
15
|
+
ESM-only, Node 20.9 or later.
|
|
16
|
+
|
|
17
|
+
## What it provides
|
|
18
|
+
|
|
19
|
+
- `AuthLinkProvider`, `useAuthLink`, `useHasRoles`: session context, hydrated from the server through `initialUser`
|
|
20
|
+
- `SignInButton`, `SignOutButton`, `UserMenu`: unstyled primitives
|
|
21
|
+
- `RequireRoles`: hides UI by role (never use it to gate content; enforce on the server)
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { AuthLinkProvider, UserMenu } from '@payload-auth-link/react'
|
|
27
|
+
|
|
28
|
+
export function Layout({ user, children }) {
|
|
29
|
+
return (
|
|
30
|
+
<AuthLinkProvider initialUser={user}>
|
|
31
|
+
<UserMenu signOutRedirectTo="/" />
|
|
32
|
+
{children}
|
|
33
|
+
</AuthLinkProvider>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Docs
|
|
39
|
+
|
|
40
|
+
- [Getting started](https://github.com/jellologic/payload-auth-link/blob/main/docs/getting-started.md)
|
|
41
|
+
- [Compatibility](https://github.com/jellologic/payload-auth-link/blob/main/docs/compatibility.md) · [Troubleshooting](https://github.com/jellologic/payload-auth-link/blob/main/docs/troubleshooting.md) · [Changelog](./CHANGELOG.md)
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
/** {@link SignInButton} wiring; unstyled by design. */
|
|
3
|
+
export type SignInButtonProps = {
|
|
4
|
+
/** Where the sign-in page lives. Defaults to `/sign-in`. */
|
|
5
|
+
readonly href?: string | undefined;
|
|
6
|
+
readonly className?: string | undefined;
|
|
7
|
+
readonly children?: ReactNode | undefined;
|
|
8
|
+
};
|
|
9
|
+
/** Unstyled link to the builder's sign-in page. */
|
|
10
|
+
export declare function SignInButton({ href, className, children, }: SignInButtonProps): ReactNode;
|
|
11
|
+
/** {@link SignOutButton} wiring; navigation is injectable. */
|
|
12
|
+
export type SignOutButtonProps = {
|
|
13
|
+
readonly className?: string | undefined;
|
|
14
|
+
readonly children?: ReactNode | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Where to go after a successful sign-out. Stays put by default.
|
|
17
|
+
* Narrowed by `safeRedirectPath` (an off-origin or API destination
|
|
18
|
+
* falls back to `/`), so a prop fed from a query param cannot turn
|
|
19
|
+
* sign-out into an open redirect.
|
|
20
|
+
*/
|
|
21
|
+
readonly redirectTo?: string | undefined;
|
|
22
|
+
/** Navigation injection for tests; defaults to a full page load. */
|
|
23
|
+
readonly navigate?: ((url: string) => void) | undefined;
|
|
24
|
+
/** Called after a successful sign-out, before navigation. */
|
|
25
|
+
readonly onSignedOut?: (() => void) | undefined;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Unstyled sign-out button. Revokes through the provider; on failure it
|
|
29
|
+
* does nothing (the session is still live, so navigating away would lie).
|
|
30
|
+
*/
|
|
31
|
+
export declare function SignOutButton({ className, children, redirectTo, navigate, onSignedOut, }: SignOutButtonProps): ReactNode;
|
|
32
|
+
/** {@link UserMenu} wiring; renders nothing while loading. */
|
|
33
|
+
export type UserMenuProps = {
|
|
34
|
+
readonly className?: string | undefined;
|
|
35
|
+
/** Sign-in page for the anonymous state. Defaults to `/sign-in`. */
|
|
36
|
+
readonly signInHref?: string | undefined;
|
|
37
|
+
/** Where to go after sign-out. Stays put by default. */
|
|
38
|
+
readonly signOutRedirectTo?: string | undefined;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Minimal session menu: nothing while loading, a sign-in link when
|
|
42
|
+
* anonymous, the email plus a sign-out button when authenticated.
|
|
43
|
+
*/
|
|
44
|
+
export declare function UserMenu({ className, signInHref, signOutRedirectTo, }: UserMenuProps): ReactNode;
|
|
45
|
+
/** {@link RequireRoles} wiring: all roles required, fail closed. */
|
|
46
|
+
export type RequireRolesProps = {
|
|
47
|
+
/** Every listed role is required (fail closed while loading). */
|
|
48
|
+
readonly roles: readonly string[];
|
|
49
|
+
readonly children: ReactNode;
|
|
50
|
+
readonly fallback?: ReactNode | undefined;
|
|
51
|
+
};
|
|
52
|
+
/** Render-gate for role-restricted UI. Hides — it never authorizes. */
|
|
53
|
+
export declare function RequireRoles({ roles, children, fallback }: RequireRolesProps): ReactNode;
|
|
54
|
+
//# sourceMappingURL=components.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../src/components.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtC,uDAAuD;AACvD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;CAC1C,CAAA;AAED,mDAAmD;AACnD,wBAAgB,YAAY,CAAC,EAC3B,IAAiB,EACjB,SAAS,EACT,QAAoB,GACrB,EAAE,iBAAiB,GAAG,SAAS,CAM/B;AAED,8DAA8D;AAC9D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxC,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAA;IACvD,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAA;CAChD,CAAA;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAC5B,SAAS,EACT,QAAqB,EACrB,UAAU,EACV,QAEC,EACD,WAAW,GACZ,EAAE,kBAAkB,GAAG,SAAS,CAuBhC;AAED,8DAA8D;AAC9D,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,oEAAoE;IACpE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxC,wDAAwD;IACxD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAChD,CAAA;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,EACvB,SAAS,EACT,UAAuB,EACvB,iBAAiB,GAClB,EAAE,aAAa,GAAG,SAAS,CAc3B;AAED,oEAAoE;AACpE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;IACjC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;CAC1C,CAAA;AAED,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAe,EAAE,EAAE,iBAAiB,GAAG,SAAS,CAG/F"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { safeRedirectPath } from '@payload-auth-link/core';
|
|
4
|
+
import { useAuthLink, useHasRoles } from './provider.js';
|
|
5
|
+
/** Unstyled link to the builder's sign-in page. */
|
|
6
|
+
export function SignInButton({ href = '/sign-in', className, children = 'Sign in', }) {
|
|
7
|
+
return (_jsx("a", { href: href, className: className, "data-auth-link": "sign-in", children: children }));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Unstyled sign-out button. Revokes through the provider; on failure it
|
|
11
|
+
* does nothing (the session is still live, so navigating away would lie).
|
|
12
|
+
*/
|
|
13
|
+
export function SignOutButton({ className, children = 'Sign out', redirectTo, navigate = (url) => {
|
|
14
|
+
window.location.assign(url);
|
|
15
|
+
}, onSignedOut, }) {
|
|
16
|
+
const { signOut } = useAuthLink();
|
|
17
|
+
return (_jsx("button", { type: "button", className: className, "data-auth-link": "sign-out", onClick: () => {
|
|
18
|
+
void (async () => {
|
|
19
|
+
if (await signOut()) {
|
|
20
|
+
onSignedOut?.();
|
|
21
|
+
if (redirectTo !== undefined) {
|
|
22
|
+
// Sign-in is a fine post-sign-out destination: disable
|
|
23
|
+
// that rule, keep the off-origin and API ones.
|
|
24
|
+
navigate(safeRedirectPath(redirectTo, '/', { signInPath: null }));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
}, children: children }));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Minimal session menu: nothing while loading, a sign-in link when
|
|
32
|
+
* anonymous, the email plus a sign-out button when authenticated.
|
|
33
|
+
*/
|
|
34
|
+
export function UserMenu({ className, signInHref = '/sign-in', signOutRedirectTo, }) {
|
|
35
|
+
const { user, status } = useAuthLink();
|
|
36
|
+
if (status === 'loading') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (!user) {
|
|
40
|
+
return _jsx(SignInButton, { href: signInHref });
|
|
41
|
+
}
|
|
42
|
+
return (_jsxs("div", { className: className, "data-auth-link": "user-menu", "data-auth-status": status, children: [_jsx("span", { "data-auth-link": "user-email", children: user.email }), ' ', _jsx(SignOutButton, { redirectTo: signOutRedirectTo })] }));
|
|
43
|
+
}
|
|
44
|
+
/** Render-gate for role-restricted UI. Hides — it never authorizes. */
|
|
45
|
+
export function RequireRoles({ roles, children, fallback = null }) {
|
|
46
|
+
const allowed = useHasRoles(...roles);
|
|
47
|
+
return allowed ? children : fallback;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=components.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.js","sourceRoot":"","sources":["../src/components.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAE1D,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAUxD,mDAAmD;AACnD,MAAM,UAAU,YAAY,CAAC,EAC3B,IAAI,GAAG,UAAU,EACjB,SAAS,EACT,QAAQ,GAAG,SAAS,GACF;IAClB,OAAO,CACL,YAAG,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,oBAAiB,SAAS,YAC1D,QAAQ,GACP,CACL,CAAA;AACH,CAAC;AAmBD;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,EAC5B,SAAS,EACT,QAAQ,GAAG,UAAU,EACrB,UAAU,EACV,QAAQ,GAAG,CAAC,GAAW,EAAQ,EAAE;IAC/B,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;AAC7B,CAAC,EACD,WAAW,GACQ;IACnB,MAAM,EAAE,OAAO,EAAE,GAAG,WAAW,EAAE,CAAA;IACjC,OAAO,CACL,iBACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAE,SAAS,oBACL,UAAU,EACzB,OAAO,EAAE,GAAG,EAAE;YACZ,KAAK,CAAC,KAAK,IAAI,EAAE;gBACf,IAAI,MAAM,OAAO,EAAE,EAAE,CAAC;oBACpB,WAAW,EAAE,EAAE,CAAA;oBACf,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;wBAC7B,uDAAuD;wBACvD,+CAA+C;wBAC/C,QAAQ,CAAC,gBAAgB,CAAC,UAAU,EAAE,GAAG,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;oBACnE,CAAC;gBACH,CAAC;YACH,CAAC,CAAC,EAAE,CAAA;QACN,CAAC,YAEA,QAAQ,GACF,CACV,CAAA;AACH,CAAC;AAWD;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,EACvB,SAAS,EACT,UAAU,GAAG,UAAU,EACvB,iBAAiB,GACH;IACd,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,WAAW,EAAE,CAAA;IACtC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,IAAI,CAAA;IACb,CAAC;IACD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,KAAC,YAAY,IAAC,IAAI,EAAE,UAAU,GAAI,CAAA;IAC3C,CAAC;IACD,OAAO,CACL,eAAK,SAAS,EAAE,SAAS,oBAAiB,WAAW,sBAAmB,MAAM,aAC5E,iCAAqB,YAAY,YAAE,IAAI,CAAC,KAAK,GAAQ,EAAC,GAAG,EACzD,KAAC,aAAa,IAAC,UAAU,EAAE,iBAAiB,GAAI,IAC5C,CACP,CAAA;AACH,CAAC;AAUD,uEAAuE;AACvE,MAAM,UAAU,YAAY,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAqB;IAClF,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,KAAK,CAAC,CAAA;IACrC,OAAO,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAA;AACtC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic React UI: session context, sign-in components, user
|
|
3
|
+
* menu, role gates. No Next, no Payload imports — framework work lives in
|
|
4
|
+
* `@payload-auth-link/next`, server work in `@payload-auth-link/plugin`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const packageName = "@payload-auth-link/react";
|
|
7
|
+
export { RequireRoles, SignInButton, SignOutButton, UserMenu } from './components.js';
|
|
8
|
+
export type { RequireRolesProps, SignInButtonProps, SignOutButtonProps, UserMenuProps, } from './components.js';
|
|
9
|
+
export { AuthLinkProvider, useAuthLink, useHasRoles } from './provider.js';
|
|
10
|
+
export type { AuthLinkContextValue, AuthLinkProviderProps, AuthLinkStatus } from './provider.js';
|
|
11
|
+
export { fetchSessionUser, revokeSession } from './session.js';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,WAAW,6BAA6B,CAAA;AAErD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACrF,YAAY,EACV,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,GACd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC1E,YAAY,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAChG,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic React UI: session context, sign-in components, user
|
|
3
|
+
* menu, role gates. No Next, no Payload imports — framework work lives in
|
|
4
|
+
* `@payload-auth-link/next`, server work in `@payload-auth-link/plugin`.
|
|
5
|
+
*/
|
|
6
|
+
export const packageName = '@payload-auth-link/react';
|
|
7
|
+
export { RequireRoles, SignInButton, SignOutButton, UserMenu } from './components.js';
|
|
8
|
+
export { AuthLinkProvider, useAuthLink, useHasRoles } from './provider.js';
|
|
9
|
+
export { fetchSessionUser, revokeSession } from './session.js';
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,0BAA0B,CAAA;AAErD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAOrF,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE1E,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type HttpFetch, type SessionUser } from '@payload-auth-link/core';
|
|
3
|
+
/** Session state: unknown until the first load settles. */
|
|
4
|
+
export type AuthLinkStatus = 'loading' | 'authenticated' | 'anonymous';
|
|
5
|
+
/** What `useAuthLink` returns: user, status, refresh, honest sign-out. */
|
|
6
|
+
export type AuthLinkContextValue = {
|
|
7
|
+
readonly user: SessionUser | null;
|
|
8
|
+
readonly status: AuthLinkStatus;
|
|
9
|
+
/** Re-read the session endpoint (call after sign-in or role changes). */
|
|
10
|
+
readonly refresh: () => Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Revoke the session and drop local state. Resolves `false` when the
|
|
13
|
+
* revocation failed — local state is kept so the UI stays honest.
|
|
14
|
+
*/
|
|
15
|
+
readonly signOut: () => Promise<boolean>;
|
|
16
|
+
};
|
|
17
|
+
/** Client provider wiring; `initialUser` is the hydration contract. */
|
|
18
|
+
export type AuthLinkProviderProps = {
|
|
19
|
+
readonly children: ReactNode;
|
|
20
|
+
/** Session endpoint path. Defaults to the plugin's endpoint. */
|
|
21
|
+
readonly sessionEndpoint?: string | undefined;
|
|
22
|
+
/** Injectible fetch. Defaults to the global `fetch`. */
|
|
23
|
+
readonly fetchImpl?: HttpFetch | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Hydration contract: `undefined` (default) fetches on mount;
|
|
26
|
+
* `SessionUser` starts authenticated without a fetch; `null` starts
|
|
27
|
+
* anonymous without a fetch. The Next adapter's server provider
|
|
28
|
+
* supplies this from full server-side validation.
|
|
29
|
+
*/
|
|
30
|
+
readonly initialUser?: SessionUser | null | undefined;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Client session provider: loads once on mount (unless hydrated),
|
|
34
|
+
* exposes refresh/sign-out. Fail-closed — fetch errors mean anonymous.
|
|
35
|
+
*
|
|
36
|
+
* A later `initialUser` (a re-run server loader, a new RSC payload) is
|
|
37
|
+
* synced into context when its value changes; re-renders passing the
|
|
38
|
+
* same user as a fresh object change nothing, so a client-side sign-out
|
|
39
|
+
* is never undone by a stale snapshot. Refresh stays explicit.
|
|
40
|
+
*/
|
|
41
|
+
export declare function AuthLinkProvider({ children, sessionEndpoint, fetchImpl, initialUser, }: AuthLinkProviderProps): ReactNode;
|
|
42
|
+
/** Read the auth context. Throws outside `AuthLinkProvider` — fail loud. */
|
|
43
|
+
export declare function useAuthLink(): AuthLinkContextValue;
|
|
44
|
+
/** `true` when the current user holds every listed role. Fails closed. */
|
|
45
|
+
export declare function useHasRoles(...roles: readonly string[]): boolean;
|
|
46
|
+
//# sourceMappingURL=provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;AACd,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,yBAAyB,CAAA;AAGhC,2DAA2D;AAC3D,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,eAAe,GAAG,WAAW,CAAA;AAEtE,0EAA0E;AAC1E,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACjC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAA;IAC/B,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;CACzC,CAAA;AAID,uEAAuE;AACvE,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,gEAAgE;IAChE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C,wDAAwD;IACxD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC1C;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,CAAA;CACtD,CAAA;AAiBD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,eAA4C,EAC5C,SAAiB,EACjB,WAAuB,GACxB,EAAE,qBAAqB,GAAG,SAAS,CA6DnC;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,IAAI,oBAAoB,CAMlD;AAED,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,GAAG,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAMhE"}
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
4
|
+
import { defaultSessionEndpointPath, } from '@payload-auth-link/core';
|
|
5
|
+
import { fetchSessionUser, revokeSession } from './session.js';
|
|
6
|
+
const AuthLinkContext = createContext(null);
|
|
7
|
+
/** Value identity for hydration snapshots (fresh objects, same user). */
|
|
8
|
+
function snapshotKey(user) {
|
|
9
|
+
if (user === undefined) {
|
|
10
|
+
return 'undefined';
|
|
11
|
+
}
|
|
12
|
+
if (user === null) {
|
|
13
|
+
return 'null';
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify([user.id, user.collection, user.email, [...user.roles].sort()]);
|
|
16
|
+
}
|
|
17
|
+
function statusFor(user) {
|
|
18
|
+
return user === null ? 'anonymous' : 'authenticated';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Client session provider: loads once on mount (unless hydrated),
|
|
22
|
+
* exposes refresh/sign-out. Fail-closed — fetch errors mean anonymous.
|
|
23
|
+
*
|
|
24
|
+
* A later `initialUser` (a re-run server loader, a new RSC payload) is
|
|
25
|
+
* synced into context when its value changes; re-renders passing the
|
|
26
|
+
* same user as a fresh object change nothing, so a client-side sign-out
|
|
27
|
+
* is never undone by a stale snapshot. Refresh stays explicit.
|
|
28
|
+
*/
|
|
29
|
+
export function AuthLinkProvider({ children, sessionEndpoint = defaultSessionEndpointPath, fetchImpl = fetch, initialUser = undefined, }) {
|
|
30
|
+
const [user, setUser] = useState(initialUser ?? null);
|
|
31
|
+
const [status, setStatus] = useState(initialUser === undefined ? 'loading' : statusFor(initialUser));
|
|
32
|
+
// React's "adjust state when a prop changes" pattern (during render,
|
|
33
|
+
// not in an effect): no stale frame, no set-state-in-effect.
|
|
34
|
+
const [syncedKey, setSyncedKey] = useState(() => snapshotKey(initialUser));
|
|
35
|
+
const incomingKey = snapshotKey(initialUser);
|
|
36
|
+
if (incomingKey !== syncedKey) {
|
|
37
|
+
setSyncedKey(incomingKey);
|
|
38
|
+
if (initialUser !== undefined) {
|
|
39
|
+
setUser(initialUser);
|
|
40
|
+
setStatus(statusFor(initialUser));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const loadSession = useCallback(() => fetchSessionUser(sessionEndpoint, fetchImpl), [sessionEndpoint, fetchImpl]);
|
|
44
|
+
const refresh = useCallback(async () => {
|
|
45
|
+
const current = await loadSession();
|
|
46
|
+
setUser(current);
|
|
47
|
+
setStatus(current === null ? 'anonymous' : 'authenticated');
|
|
48
|
+
}, [loadSession]);
|
|
49
|
+
const signOut = useCallback(async () => {
|
|
50
|
+
const revoked = await revokeSession(sessionEndpoint, fetchImpl);
|
|
51
|
+
if (revoked) {
|
|
52
|
+
setUser(null);
|
|
53
|
+
setStatus('anonymous');
|
|
54
|
+
}
|
|
55
|
+
return revoked;
|
|
56
|
+
}, [sessionEndpoint, fetchImpl]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (initialUser !== undefined) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let cancelled = false;
|
|
62
|
+
// Mount-only initial load (the async boundary keeps this out of the
|
|
63
|
+
// sync-setState-in-effect rule); `refresh` stays the public re-read.
|
|
64
|
+
void (async () => {
|
|
65
|
+
const current = await loadSession();
|
|
66
|
+
if (!cancelled) {
|
|
67
|
+
setUser(current);
|
|
68
|
+
setStatus(current === null ? 'anonymous' : 'authenticated');
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
return () => {
|
|
72
|
+
cancelled = true;
|
|
73
|
+
};
|
|
74
|
+
}, [initialUser, loadSession]);
|
|
75
|
+
const value = useMemo(() => ({ user, status, refresh, signOut }), [user, status, refresh, signOut]);
|
|
76
|
+
return _jsx(AuthLinkContext.Provider, { value: value, children: children });
|
|
77
|
+
}
|
|
78
|
+
/** Read the auth context. Throws outside `AuthLinkProvider` — fail loud. */
|
|
79
|
+
export function useAuthLink() {
|
|
80
|
+
const value = useContext(AuthLinkContext);
|
|
81
|
+
if (!value) {
|
|
82
|
+
throw new Error('useAuthLink must be used within AuthLinkProvider');
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
/** `true` when the current user holds every listed role. Fails closed. */
|
|
87
|
+
export function useHasRoles(...roles) {
|
|
88
|
+
const { user } = useAuthLink();
|
|
89
|
+
if (!user) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return roles.every((role) => user.roles.includes(role));
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider.js","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HttpFetch, type SessionUser } from '@payload-auth-link/core';
|
|
2
|
+
/**
|
|
3
|
+
* Read the current session user from the plugin's session endpoint
|
|
4
|
+
* (`{ user }` envelope). Fails closed: transport errors, non-2xx
|
|
5
|
+
* statuses, invalid JSON, and malformed envelopes all yield `null`,
|
|
6
|
+
* never an exception and never a partial user.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fetchSessionUser(sessionEndpoint?: string, fetchImpl?: HttpFetch): Promise<SessionUser | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Revoke the current session through the plugin's session endpoint.
|
|
11
|
+
* Sends the CSRF header the endpoint requires. Returns `true` when the
|
|
12
|
+
* session is gone afterwards: `2xx`, or `401` (already signed out —
|
|
13
|
+
* logout is idempotent). Transport errors and other statuses return
|
|
14
|
+
* `false` so the caller can report instead of lying.
|
|
15
|
+
*/
|
|
16
|
+
export declare function revokeSession(sessionEndpoint?: string, fetchImpl?: HttpFetch): Promise<boolean>;
|
|
17
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,yBAAyB,CAAA;AAEhC;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,eAAe,GAAE,MAAmC,EACpD,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAe7B;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,eAAe,GAAE,MAAmC,EACpD,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,OAAO,CAAC,CAYlB"}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { csrfHeaderName, defaultSessionEndpointPath, parseSessionResponse, } from '@payload-auth-link/core';
|
|
2
|
+
/**
|
|
3
|
+
* Read the current session user from the plugin's session endpoint
|
|
4
|
+
* (`{ user }` envelope). Fails closed: transport errors, non-2xx
|
|
5
|
+
* statuses, invalid JSON, and malformed envelopes all yield `null`,
|
|
6
|
+
* never an exception and never a partial user.
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchSessionUser(sessionEndpoint = defaultSessionEndpointPath, fetchImpl = fetch) {
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await fetchImpl(sessionEndpoint, {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
credentials: 'same-origin',
|
|
14
|
+
// Belt and suspenders with the endpoint's `Cache-Control:
|
|
15
|
+
// no-store`: never serve this read from any cache.
|
|
16
|
+
cache: 'no-store',
|
|
17
|
+
headers: { accept: 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return parseSessionResponse(response);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Revoke the current session through the plugin's session endpoint.
|
|
27
|
+
* Sends the CSRF header the endpoint requires. Returns `true` when the
|
|
28
|
+
* session is gone afterwards: `2xx`, or `401` (already signed out —
|
|
29
|
+
* logout is idempotent). Transport errors and other statuses return
|
|
30
|
+
* `false` so the caller can report instead of lying.
|
|
31
|
+
*/
|
|
32
|
+
export async function revokeSession(sessionEndpoint = defaultSessionEndpointPath, fetchImpl = fetch) {
|
|
33
|
+
let response;
|
|
34
|
+
try {
|
|
35
|
+
response = await fetchImpl(sessionEndpoint, {
|
|
36
|
+
method: 'DELETE',
|
|
37
|
+
credentials: 'same-origin',
|
|
38
|
+
headers: { [csrfHeaderName]: '1' },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return response.ok || response.status === 401;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,0BAA0B,EAC1B,oBAAoB,GAGrB,MAAM,yBAAyB,CAAA;AAEhC;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,kBAA0B,0BAA0B,EACpD,YAAuB,KAAK;IAE5B,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,eAAe,EAAE;YAC1C,MAAM,EAAE,KAAK;YACb,WAAW,EAAE,aAAa;YAC1B,0DAA0D;YAC1D,mDAAmD;YACnD,KAAK,EAAE,UAAU;YACjB,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;SACxC,CAAC,CAAA;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;IACD,OAAO,oBAAoB,CAAC,QAAQ,CAAC,CAAA;AACvC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,kBAA0B,0BAA0B,EACpD,YAAuB,KAAK;IAE5B,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,eAAe,EAAE;YAC1C,MAAM,EAAE,QAAQ;YAChB,WAAW,EAAE,aAAa;YAC1B,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,EAAE,GAAG,EAAE;SACnC,CAAC,CAAA;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;IACD,OAAO,QAAQ,CAAC,EAAE,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,CAAA;AAC/C,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payload-auth-link/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "payload-auth-link React UI: provider-agnostic sign-in components and user menu.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jellologic/payload-auth-link.git",
|
|
9
|
+
"directory": "packages/react"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/jellologic/payload-auth-link/tree/main/packages/react#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/jellologic/payload-auth-link/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"!dist/*.tsbuildinfo",
|
|
28
|
+
"src",
|
|
29
|
+
"!src/**/__tests__",
|
|
30
|
+
"!dist/**/__tests__",
|
|
31
|
+
"!dist/**/__fixtures__",
|
|
32
|
+
"!src/**/__fixtures__"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -b",
|
|
36
|
+
"typecheck": "tsc -b",
|
|
37
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@payload-auth-link/core": "0.1.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": "^19.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"react": "19.2.6"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"provenance": true
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20.9.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { safeRedirectPath } from '@payload-auth-link/core'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import { useAuthLink, useHasRoles } from './provider.js'
|
|
6
|
+
|
|
7
|
+
/** {@link SignInButton} wiring; unstyled by design. */
|
|
8
|
+
export type SignInButtonProps = {
|
|
9
|
+
/** Where the sign-in page lives. Defaults to `/sign-in`. */
|
|
10
|
+
readonly href?: string | undefined
|
|
11
|
+
readonly className?: string | undefined
|
|
12
|
+
readonly children?: ReactNode | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Unstyled link to the builder's sign-in page. */
|
|
16
|
+
export function SignInButton({
|
|
17
|
+
href = '/sign-in',
|
|
18
|
+
className,
|
|
19
|
+
children = 'Sign in',
|
|
20
|
+
}: SignInButtonProps): ReactNode {
|
|
21
|
+
return (
|
|
22
|
+
<a href={href} className={className} data-auth-link="sign-in">
|
|
23
|
+
{children}
|
|
24
|
+
</a>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** {@link SignOutButton} wiring; navigation is injectable. */
|
|
29
|
+
export type SignOutButtonProps = {
|
|
30
|
+
readonly className?: string | undefined
|
|
31
|
+
readonly children?: ReactNode | undefined
|
|
32
|
+
/**
|
|
33
|
+
* Where to go after a successful sign-out. Stays put by default.
|
|
34
|
+
* Narrowed by `safeRedirectPath` (an off-origin or API destination
|
|
35
|
+
* falls back to `/`), so a prop fed from a query param cannot turn
|
|
36
|
+
* sign-out into an open redirect.
|
|
37
|
+
*/
|
|
38
|
+
readonly redirectTo?: string | undefined
|
|
39
|
+
/** Navigation injection for tests; defaults to a full page load. */
|
|
40
|
+
readonly navigate?: ((url: string) => void) | undefined
|
|
41
|
+
/** Called after a successful sign-out, before navigation. */
|
|
42
|
+
readonly onSignedOut?: (() => void) | undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Unstyled sign-out button. Revokes through the provider; on failure it
|
|
47
|
+
* does nothing (the session is still live, so navigating away would lie).
|
|
48
|
+
*/
|
|
49
|
+
export function SignOutButton({
|
|
50
|
+
className,
|
|
51
|
+
children = 'Sign out',
|
|
52
|
+
redirectTo,
|
|
53
|
+
navigate = (url: string): void => {
|
|
54
|
+
window.location.assign(url)
|
|
55
|
+
},
|
|
56
|
+
onSignedOut,
|
|
57
|
+
}: SignOutButtonProps): ReactNode {
|
|
58
|
+
const { signOut } = useAuthLink()
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
className={className}
|
|
63
|
+
data-auth-link="sign-out"
|
|
64
|
+
onClick={() => {
|
|
65
|
+
void (async () => {
|
|
66
|
+
if (await signOut()) {
|
|
67
|
+
onSignedOut?.()
|
|
68
|
+
if (redirectTo !== undefined) {
|
|
69
|
+
// Sign-in is a fine post-sign-out destination: disable
|
|
70
|
+
// that rule, keep the off-origin and API ones.
|
|
71
|
+
navigate(safeRedirectPath(redirectTo, '/', { signInPath: null }))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</button>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** {@link UserMenu} wiring; renders nothing while loading. */
|
|
83
|
+
export type UserMenuProps = {
|
|
84
|
+
readonly className?: string | undefined
|
|
85
|
+
/** Sign-in page for the anonymous state. Defaults to `/sign-in`. */
|
|
86
|
+
readonly signInHref?: string | undefined
|
|
87
|
+
/** Where to go after sign-out. Stays put by default. */
|
|
88
|
+
readonly signOutRedirectTo?: string | undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Minimal session menu: nothing while loading, a sign-in link when
|
|
93
|
+
* anonymous, the email plus a sign-out button when authenticated.
|
|
94
|
+
*/
|
|
95
|
+
export function UserMenu({
|
|
96
|
+
className,
|
|
97
|
+
signInHref = '/sign-in',
|
|
98
|
+
signOutRedirectTo,
|
|
99
|
+
}: UserMenuProps): ReactNode {
|
|
100
|
+
const { user, status } = useAuthLink()
|
|
101
|
+
if (status === 'loading') {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
if (!user) {
|
|
105
|
+
return <SignInButton href={signInHref} />
|
|
106
|
+
}
|
|
107
|
+
return (
|
|
108
|
+
<div className={className} data-auth-link="user-menu" data-auth-status={status}>
|
|
109
|
+
<span data-auth-link="user-email">{user.email}</span>{' '}
|
|
110
|
+
<SignOutButton redirectTo={signOutRedirectTo} />
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** {@link RequireRoles} wiring: all roles required, fail closed. */
|
|
116
|
+
export type RequireRolesProps = {
|
|
117
|
+
/** Every listed role is required (fail closed while loading). */
|
|
118
|
+
readonly roles: readonly string[]
|
|
119
|
+
readonly children: ReactNode
|
|
120
|
+
readonly fallback?: ReactNode | undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Render-gate for role-restricted UI. Hides — it never authorizes. */
|
|
124
|
+
export function RequireRoles({ roles, children, fallback = null }: RequireRolesProps): ReactNode {
|
|
125
|
+
const allowed = useHasRoles(...roles)
|
|
126
|
+
return allowed ? children : fallback
|
|
127
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic React UI: session context, sign-in components, user
|
|
3
|
+
* menu, role gates. No Next, no Payload imports — framework work lives in
|
|
4
|
+
* `@payload-auth-link/next`, server work in `@payload-auth-link/plugin`.
|
|
5
|
+
*/
|
|
6
|
+
export const packageName = '@payload-auth-link/react'
|
|
7
|
+
|
|
8
|
+
export { RequireRoles, SignInButton, SignOutButton, UserMenu } from './components.js'
|
|
9
|
+
export type {
|
|
10
|
+
RequireRolesProps,
|
|
11
|
+
SignInButtonProps,
|
|
12
|
+
SignOutButtonProps,
|
|
13
|
+
UserMenuProps,
|
|
14
|
+
} from './components.js'
|
|
15
|
+
export { AuthLinkProvider, useAuthLink, useHasRoles } from './provider.js'
|
|
16
|
+
export type { AuthLinkContextValue, AuthLinkProviderProps, AuthLinkStatus } from './provider.js'
|
|
17
|
+
export { fetchSessionUser, revokeSession } from './session.js'
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import {
|
|
13
|
+
defaultSessionEndpointPath,
|
|
14
|
+
type HttpFetch,
|
|
15
|
+
type SessionUser,
|
|
16
|
+
} from '@payload-auth-link/core'
|
|
17
|
+
import { fetchSessionUser, revokeSession } from './session.js'
|
|
18
|
+
|
|
19
|
+
/** Session state: unknown until the first load settles. */
|
|
20
|
+
export type AuthLinkStatus = 'loading' | 'authenticated' | 'anonymous'
|
|
21
|
+
|
|
22
|
+
/** What `useAuthLink` returns: user, status, refresh, honest sign-out. */
|
|
23
|
+
export type AuthLinkContextValue = {
|
|
24
|
+
readonly user: SessionUser | null
|
|
25
|
+
readonly status: AuthLinkStatus
|
|
26
|
+
/** Re-read the session endpoint (call after sign-in or role changes). */
|
|
27
|
+
readonly refresh: () => Promise<void>
|
|
28
|
+
/**
|
|
29
|
+
* Revoke the session and drop local state. Resolves `false` when the
|
|
30
|
+
* revocation failed — local state is kept so the UI stays honest.
|
|
31
|
+
*/
|
|
32
|
+
readonly signOut: () => Promise<boolean>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const AuthLinkContext = createContext<AuthLinkContextValue | null>(null)
|
|
36
|
+
|
|
37
|
+
/** Client provider wiring; `initialUser` is the hydration contract. */
|
|
38
|
+
export type AuthLinkProviderProps = {
|
|
39
|
+
readonly children: ReactNode
|
|
40
|
+
/** Session endpoint path. Defaults to the plugin's endpoint. */
|
|
41
|
+
readonly sessionEndpoint?: string | undefined
|
|
42
|
+
/** Injectible fetch. Defaults to the global `fetch`. */
|
|
43
|
+
readonly fetchImpl?: HttpFetch | undefined
|
|
44
|
+
/**
|
|
45
|
+
* Hydration contract: `undefined` (default) fetches on mount;
|
|
46
|
+
* `SessionUser` starts authenticated without a fetch; `null` starts
|
|
47
|
+
* anonymous without a fetch. The Next adapter's server provider
|
|
48
|
+
* supplies this from full server-side validation.
|
|
49
|
+
*/
|
|
50
|
+
readonly initialUser?: SessionUser | null | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Value identity for hydration snapshots (fresh objects, same user). */
|
|
54
|
+
function snapshotKey(user: SessionUser | null | undefined): string {
|
|
55
|
+
if (user === undefined) {
|
|
56
|
+
return 'undefined'
|
|
57
|
+
}
|
|
58
|
+
if (user === null) {
|
|
59
|
+
return 'null'
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify([user.id, user.collection, user.email, [...user.roles].sort()])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function statusFor(user: SessionUser | null): AuthLinkStatus {
|
|
65
|
+
return user === null ? 'anonymous' : 'authenticated'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Client session provider: loads once on mount (unless hydrated),
|
|
70
|
+
* exposes refresh/sign-out. Fail-closed — fetch errors mean anonymous.
|
|
71
|
+
*
|
|
72
|
+
* A later `initialUser` (a re-run server loader, a new RSC payload) is
|
|
73
|
+
* synced into context when its value changes; re-renders passing the
|
|
74
|
+
* same user as a fresh object change nothing, so a client-side sign-out
|
|
75
|
+
* is never undone by a stale snapshot. Refresh stays explicit.
|
|
76
|
+
*/
|
|
77
|
+
export function AuthLinkProvider({
|
|
78
|
+
children,
|
|
79
|
+
sessionEndpoint = defaultSessionEndpointPath,
|
|
80
|
+
fetchImpl = fetch,
|
|
81
|
+
initialUser = undefined,
|
|
82
|
+
}: AuthLinkProviderProps): ReactNode {
|
|
83
|
+
const [user, setUser] = useState<SessionUser | null>(initialUser ?? null)
|
|
84
|
+
const [status, setStatus] = useState<AuthLinkStatus>(
|
|
85
|
+
initialUser === undefined ? 'loading' : statusFor(initialUser)
|
|
86
|
+
)
|
|
87
|
+
// React's "adjust state when a prop changes" pattern (during render,
|
|
88
|
+
// not in an effect): no stale frame, no set-state-in-effect.
|
|
89
|
+
const [syncedKey, setSyncedKey] = useState(() => snapshotKey(initialUser))
|
|
90
|
+
const incomingKey = snapshotKey(initialUser)
|
|
91
|
+
if (incomingKey !== syncedKey) {
|
|
92
|
+
setSyncedKey(incomingKey)
|
|
93
|
+
if (initialUser !== undefined) {
|
|
94
|
+
setUser(initialUser)
|
|
95
|
+
setStatus(statusFor(initialUser))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const loadSession = useCallback(
|
|
100
|
+
() => fetchSessionUser(sessionEndpoint, fetchImpl),
|
|
101
|
+
[sessionEndpoint, fetchImpl]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const refresh = useCallback(async () => {
|
|
105
|
+
const current = await loadSession()
|
|
106
|
+
setUser(current)
|
|
107
|
+
setStatus(current === null ? 'anonymous' : 'authenticated')
|
|
108
|
+
}, [loadSession])
|
|
109
|
+
|
|
110
|
+
const signOut = useCallback(async () => {
|
|
111
|
+
const revoked = await revokeSession(sessionEndpoint, fetchImpl)
|
|
112
|
+
if (revoked) {
|
|
113
|
+
setUser(null)
|
|
114
|
+
setStatus('anonymous')
|
|
115
|
+
}
|
|
116
|
+
return revoked
|
|
117
|
+
}, [sessionEndpoint, fetchImpl])
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (initialUser !== undefined) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
let cancelled = false
|
|
124
|
+
// Mount-only initial load (the async boundary keeps this out of the
|
|
125
|
+
// sync-setState-in-effect rule); `refresh` stays the public re-read.
|
|
126
|
+
void (async () => {
|
|
127
|
+
const current = await loadSession()
|
|
128
|
+
if (!cancelled) {
|
|
129
|
+
setUser(current)
|
|
130
|
+
setStatus(current === null ? 'anonymous' : 'authenticated')
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
return () => {
|
|
134
|
+
cancelled = true
|
|
135
|
+
}
|
|
136
|
+
}, [initialUser, loadSession])
|
|
137
|
+
|
|
138
|
+
const value = useMemo<AuthLinkContextValue>(
|
|
139
|
+
() => ({ user, status, refresh, signOut }),
|
|
140
|
+
[user, status, refresh, signOut]
|
|
141
|
+
)
|
|
142
|
+
return <AuthLinkContext.Provider value={value}>{children}</AuthLinkContext.Provider>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read the auth context. Throws outside `AuthLinkProvider` — fail loud. */
|
|
146
|
+
export function useAuthLink(): AuthLinkContextValue {
|
|
147
|
+
const value = useContext(AuthLinkContext)
|
|
148
|
+
if (!value) {
|
|
149
|
+
throw new Error('useAuthLink must be used within AuthLinkProvider')
|
|
150
|
+
}
|
|
151
|
+
return value
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `true` when the current user holds every listed role. Fails closed. */
|
|
155
|
+
export function useHasRoles(...roles: readonly string[]): boolean {
|
|
156
|
+
const { user } = useAuthLink()
|
|
157
|
+
if (!user) {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
return roles.every((role) => user.roles.includes(role))
|
|
161
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
csrfHeaderName,
|
|
3
|
+
defaultSessionEndpointPath,
|
|
4
|
+
parseSessionResponse,
|
|
5
|
+
type HttpFetch,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
} from '@payload-auth-link/core'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the current session user from the plugin's session endpoint
|
|
11
|
+
* (`{ user }` envelope). Fails closed: transport errors, non-2xx
|
|
12
|
+
* statuses, invalid JSON, and malformed envelopes all yield `null`,
|
|
13
|
+
* never an exception and never a partial user.
|
|
14
|
+
*/
|
|
15
|
+
export async function fetchSessionUser(
|
|
16
|
+
sessionEndpoint: string = defaultSessionEndpointPath,
|
|
17
|
+
fetchImpl: HttpFetch = fetch
|
|
18
|
+
): Promise<SessionUser | null> {
|
|
19
|
+
let response: Response
|
|
20
|
+
try {
|
|
21
|
+
response = await fetchImpl(sessionEndpoint, {
|
|
22
|
+
method: 'GET',
|
|
23
|
+
credentials: 'same-origin',
|
|
24
|
+
// Belt and suspenders with the endpoint's `Cache-Control:
|
|
25
|
+
// no-store`: never serve this read from any cache.
|
|
26
|
+
cache: 'no-store',
|
|
27
|
+
headers: { accept: 'application/json' },
|
|
28
|
+
})
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
return parseSessionResponse(response)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Revoke the current session through the plugin's session endpoint.
|
|
37
|
+
* Sends the CSRF header the endpoint requires. Returns `true` when the
|
|
38
|
+
* session is gone afterwards: `2xx`, or `401` (already signed out —
|
|
39
|
+
* logout is idempotent). Transport errors and other statuses return
|
|
40
|
+
* `false` so the caller can report instead of lying.
|
|
41
|
+
*/
|
|
42
|
+
export async function revokeSession(
|
|
43
|
+
sessionEndpoint: string = defaultSessionEndpointPath,
|
|
44
|
+
fetchImpl: HttpFetch = fetch
|
|
45
|
+
): Promise<boolean> {
|
|
46
|
+
let response: Response
|
|
47
|
+
try {
|
|
48
|
+
response = await fetchImpl(sessionEndpoint, {
|
|
49
|
+
method: 'DELETE',
|
|
50
|
+
credentials: 'same-origin',
|
|
51
|
+
headers: { [csrfHeaderName]: '1' },
|
|
52
|
+
})
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
return response.ok || response.status === 401
|
|
57
|
+
}
|